From dcc46a83b0c1f1575fb5f814f378d814e2f98ad1 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Mon, 18 May 2026 14:23:48 +0800 Subject: [PATCH 01/27] add jsbinding support --- .github/plugin/agents/winapp.agent.md | 20 +- .../skills/winapp-cli/frameworks/SKILL.md | 10 +- .../plugin/skills/winapp-cli/setup/SKILL.md | 32 + .gitignore | 3 + README.md | 1 + docs/cli-schema.json | 248 +++++ .../fragments/skills/winapp-cli/frameworks.md | 10 +- docs/fragments/skills/winapp-cli/setup.md | 16 + docs/guides/electron/index.md | 19 +- docs/js-bindings.md | 431 +++++++++ docs/npm-usage.md | 88 +- docs/usage.md | 53 + samples/electron/test.Tests.ps1 | 51 +- scripts/generate-llm-docs.ps1 | 20 +- .../AddJsBindingsCommandTests.cs | 452 +++++++++ .../AddJsBindingsOrchestrationTests.cs | 776 +++++++++++++++ .../WinApp.Cli.Tests/BaseCommandTests.cs | 33 +- .../ConfigServiceJsBindingsTests.cs | 698 ++++++++++++++ .../DynWinrtCodegenArgvTests.cs | 348 +++++++ .../DynWinrtCodegenInvocationTests.cs | 404 ++++++++ .../DynWinrtCodegenOutputSafetyTests.cs | 250 +++++ .../DynWinrtCodegenStagingTests.cs | 185 ++++ .../GenerateJsBindingsCommandTests.cs | 138 +++ .../WinApp.Cli.Tests/InitCommandTests.cs | 495 +++++++++- .../JsBindingsPresetsTests.cs | 411 ++++++++ .../NpmWrapperVersionProviderTests.cs | 168 ++++ .../PackageManagerDetectorTests.cs | 149 +++ .../TestDoubles/FakeDynWinrtCodegenService.cs | 73 ++ .../FakeJsBindingsWorkspaceService.cs | 46 + .../UserPackageJsonServiceTests.cs | 194 ++++ .../WinmdsLockfileServiceTests.cs | 287 ++++++ .../WorkspaceSetupServiceTests.cs | 216 ++++- .../YamlPackagesHasherTests.cs | 79 ++ .../Commands/AddJsBindingsCommand.cs | 158 +++ .../Commands/GenerateJsBindingsCommand.cs | 82 ++ .../WinApp.Cli/Commands/InitCommand.cs | 109 ++- .../WinApp.Cli/Commands/JsBindingsCommand.cs | 27 + .../WinApp.Cli/Commands/NodeCommand.cs | 23 + .../WinApp.Cli/Commands/WinAppRootCommand.cs | 5 +- .../Helpers/HostBuilderExtensions.cs | 11 + .../WinApp.Cli/Models/JsBindingsConfig.cs | 43 + .../WinApp.Cli/Models/WinappConfig.cs | 3 + .../WinApp.Cli/Models/WinmdsLockfile.cs | 54 ++ .../WinApp.Cli/Services/ConfigService.cs | 513 +++++++++- .../Services/DynWinrtCodegenService.cs | 709 ++++++++++++++ .../WinApp.Cli/Services/IConfigService.cs | 5 + .../Services/IDynWinrtCodegenService.cs | 23 + .../Services/IJsBindingsWorkspaceService.cs | 55 ++ .../Services/INpmWrapperVersionProvider.cs | 12 + .../Services/IPackageManagerDetector.cs | 16 + .../Services/IUserPackageJsonService.cs | 24 + .../Services/IWinmdsLockfileService.cs | 28 + .../Services/IWorkspaceSetupService.cs | 6 +- .../WinApp.Cli/Services/JsBindingsPresets.cs | 242 +++++ .../Services/JsBindingsWorkspaceService.cs | 906 ++++++++++++++++++ .../Services/NpmWrapperVersionProvider.cs | 122 +++ .../Services/PackageManagerDetector.cs | 95 ++ .../Services/UserPackageJsonService.cs | 120 +++ .../Services/WinmdsLockfileService.cs | 171 ++++ .../Services/WorkspaceSetupService.cs | 348 +++++-- .../WinApp.Cli/Services/YamlPackagesHasher.cs | 36 + src/winapp-npm/README.md | 1 + src/winapp-npm/package-lock.json | 15 + src/winapp-npm/package.json | 3 + src/winapp-npm/scripts/generate-commands.mjs | 23 +- src/winapp-npm/scripts/generate-docs.mjs | 47 + src/winapp-npm/src/cli.ts | 18 +- src/winapp-npm/src/winapp-commands.ts | 66 ++ 68 files changed, 10381 insertions(+), 142 deletions(-) create mode 100644 docs/js-bindings.md create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/AddJsBindingsCommandTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/AddJsBindingsOrchestrationTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/ConfigServiceJsBindingsTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenArgvTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenInvocationTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenOutputSafetyTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenStagingTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/GenerateJsBindingsCommandTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/JsBindingsPresetsTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/NpmWrapperVersionProviderTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/PackageManagerDetectorTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/TestDoubles/FakeDynWinrtCodegenService.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/TestDoubles/FakeJsBindingsWorkspaceService.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/UserPackageJsonServiceTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/WinmdsLockfileServiceTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/YamlPackagesHasherTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Commands/AddJsBindingsCommand.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Commands/GenerateJsBindingsCommand.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Commands/JsBindingsCommand.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Commands/NodeCommand.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Models/JsBindingsConfig.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Models/WinmdsLockfile.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/DynWinrtCodegenService.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/IDynWinrtCodegenService.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/IJsBindingsWorkspaceService.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/INpmWrapperVersionProvider.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/IPackageManagerDetector.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/IUserPackageJsonService.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/IWinmdsLockfileService.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/JsBindingsPresets.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/JsBindingsWorkspaceService.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/NpmWrapperVersionProvider.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/PackageManagerDetector.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/UserPackageJsonService.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/WinmdsLockfileService.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/YamlPackagesHasher.cs diff --git a/.github/plugin/agents/winapp.agent.md b/.github/plugin/agents/winapp.agent.md index 60684e20..23899511 100644 --- a/.github/plugin/agents/winapp.agent.md +++ b/.github/plugin/agents/winapp.agent.md @@ -28,6 +28,8 @@ Does the project already have an appxmanifest.xml? └─ Yes ├─ Has winapp.yaml, cloned/pulled but .winapp/ folder is missing? │ └─ winapp restore + ├─ Want to add typed JS/TypeScript WinRT bindings to an existing workspace? + │ └─ npx winapp node jsbindings add --ai (or omit --ai for the full surface) ├─ Want to check for newer SDK versions? │ └─ winapp update ├─ Only need an appxmanifest.xml (no SDKs, no cert, no config)? @@ -100,6 +102,16 @@ Want to inspect or interact with a running app's UI? **Key options:** `--setup-sdks stable|preview|experimental|none` **Requires:** `winapp.yaml` +### `winapp node jsbindings add` (alias: `winapp node js-bindings add`) +**Purpose:** Layer typed JS/TS WinRT bindings (via `@microsoft/dynwinrt-codegen`) onto an existing workspace. +**When to use:** After `winapp init` on a Node/Electron host, when you want callable WinRT APIs without a native build step. +**Key options:** +- `--ai` — limit generation to the `Microsoft.WindowsAppSDK.AI` slice (the only ships-today preset) +- `--output PATH` — output directory (default `bindings/winrt`); persisted to `winapp.yaml` +- `--force` — patch an existing `jsBindings:` block (overwrites `output` and preset packages; preserves user customisations like `extraTypes` / `additionalWinmds` / `skipPackages`) +- `--config-dir` — directory containing `winapp.yaml` (default: `base-directory`) +**Requires:** `winapp.yaml` already exists; npm-only (run as `npx winapp node jsbindings add`). Never modifies `packages:` or installs SDK packages. + ### `winapp package ` (alias: `winapp pack`) **Purpose:** Create an MSIX installer from a built app. **When to use:** After building your app, when you want to create a distributable MSIX package. @@ -217,12 +229,14 @@ Want to inspect or interact with a running app's UI? ## Framework-specific guidance ### Electron -- **Setup:** `winapp init --use-defaults` → `winapp node create-addon --template cs` (or `--template cpp`) → `winapp node add-electron-debug-identity` +- **Setup:** `winapp init --use-defaults` → choose your Windows API access path: + - **JS bindings (easiest, npm-only):** `npx winapp init --use-defaults --js-bindings-ai` (or `npx winapp node jsbindings add --ai` on an existing workspace) — generates typed `bindings/winrt/*.{js,d.ts}` for the WinAppSDK AI surface, callable directly from your main/renderer process via dynwinrt. No native build step. + - **Native addons:** `winapp node create-addon --template cs` (or `--template cpp`) for C#/C++ addons when you need full WinRT access or stateful native services. + - Then: `winapp node add-electron-debug-identity` to enable identity-required APIs. - **Package:** Build with your packager (e.g., Electron Forge), then `winapp package --cert .\devcert.pfx` -- Use `winapp node create-addon` to create native C#/C++ addons for Windows APIs -- Use `winapp node add-electron-debug-identity` / `clear-electron-debug-identity` for identity management - **⚠️ Always run `npx winapp node add-electron-debug-identity` before testing any Windows API that requires package identity** — without this, APIs will fail at runtime - Guide: https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/setup.md +- JS bindings reference: https://github.com/microsoft/WinAppCli/blob/main/docs/js-bindings.md ### .NET (WPF, WinForms, Console) - **Setup:** `winapp init --use-defaults` — but if you already have a `Package.appxmanifest` (e.g., WinUI 3 apps), you likely **don't need `winapp init`**. Just ensure your `.csproj` references the `Microsoft.WindowsAppSDK` NuGet package and has the right properties for packaged builds. diff --git a/.github/plugin/skills/winapp-cli/frameworks/SKILL.md b/.github/plugin/skills/winapp-cli/frameworks/SKILL.md index 559dc7fd..6e489a0c 100644 --- a/.github/plugin/skills/winapp-cli/frameworks/SKILL.md +++ b/.github/plugin/skills/winapp-cli/frameworks/SKILL.md @@ -30,16 +30,22 @@ Use the **npm package** (`@Microsoft/WinAppCli`), **not** the standalone CLI. Th - The native winapp CLI binary bundled inside `node_modules` - A Node.js SDK with helpers for creating native C#/C++ addons - Electron-specific commands under `npx winapp node` +- `node jsbindings add` — generates typed JS/TypeScript WinRT wrappers via dynwinrt (no native build required) Quick start: ```powershell npm install --save-dev @microsoft/winappcli -npx winapp init --use-defaults -npx winapp node create-addon --template cs # create a C# native addon +npx winapp init --use-defaults --js-bindings-ai # init + generate typed AI bindings in bindings/winrt/ +# (or, if you already initialized the workspace:) +npx winapp node jsbindings add --ai # layer JS bindings onto an existing workspace +npx winapp node create-addon --template cs # create a C# native addon (for stuff dynwinrt can't drive) npx winapp node add-electron-debug-identity # register identity for debugging ``` +The `--js-bindings*` flags (and the `node jsbindings add` sub-command) are **npm-only** — they require invocation via the `@microsoft/winappcli` npm package because they pull in `@microsoft/dynwinrt-codegen` as a transitive dep. The winget / standalone install will reject these surfaces with a clear error. + Additional Electron guides: +- [JS bindings reference](https://github.com/microsoft/WinAppCli/blob/main/docs/js-bindings.md) — full `jsBindings:` yaml schema, presets, per-package classification, lockfile - [Packaging guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/packaging.md) - [C++ notification addon guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/cpp-notification-addon.md) - [WinML addon guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/winml-addon.md) diff --git a/.github/plugin/skills/winapp-cli/setup/SKILL.md b/.github/plugin/skills/winapp-cli/setup/SKILL.md index d5da4ce6..9a96b5ea 100644 --- a/.github/plugin/skills/winapp-cli/setup/SKILL.md +++ b/.github/plugin/skills/winapp-cli/setup/SKILL.md @@ -52,6 +52,22 @@ winapp init --use-defaults --setup-sdks none winapp init --use-defaults --setup-sdks preview ``` +### Add JS/TS bindings for Node / Electron apps (npm only) + +When invoked via the `@microsoft/winappcli` npm package, you can generate +typed JS/TS bindings for WinRT APIs alongside the standard init: + +```powershell +# Initialize with the AI slice of the SDK pre-wired. +npx winapp init --use-defaults --js-bindings-ai + +# Or layer bindings onto an already-initialized workspace. +npx winapp node jsbindings add --ai +``` + +Generated files land under `bindings/winrt/` and `@microsoft/dynwinrt` is +added to your `package.json` dependencies so production installs include it. + After `init`, your project will contain: - `Package.appxmanifest` — package identity and capabilities - `Assets/` — default app icons (Square44x44Logo, Square150x150Logo, etc.) @@ -173,6 +189,10 @@ Start here for initializing a Windows app with required setup. Sets up everythin | `--config-dir` | Directory to read/store configuration (default: current directory) | (none) | | `--config-only` | Only handle configuration file operations (create if missing, validate if exists). Skip package installation and other workspace setup steps. | (none) | | `--ignore-config` | Don't use configuration file for version management | (none) | +| `--js-bindings` | Generate JS/TS bindings via dynwinrt-codegen on top of the standard init flow. Adds a 'jsBindings:' block to winapp.yaml so the binding generator runs as part of init/restore. Only available when invoked via the @microsoft/winappcli npm package (npx winapp init --js-bindings). | (none) | +| `--js-bindings-ai` | Generate bindings for the 'ai' slice of the SDK. Implies --js-bindings (no need to pass it separately). For a custom slice that no preset covers, edit winapp.yaml and write your own packages: list under jsBindings. Known presets: ai. | (none) | +| `--js-bindings-lang` | Override the JS bindings language. Currently only 'js' is supported (which emits both .js and .d.ts). Reserved for forward-compat; see --js-bindings-output for activation rules. | (none) | +| `--js-bindings-output` | Override the output directory for generated JS/TS bindings (relative to workspace, default 'bindings/winrt'). Only takes effect together with --js-bindings on a fresh init; ignored on re-init when winapp.yaml already declares jsBindings:. | (none) | | `--no-gitignore` | Don't update .gitignore file | (none) | | `--setup-sdks` | SDK installation mode: 'stable' (default), 'preview', 'experimental', or 'none' (skip SDK installation) | (none) | | `--use-defaults` | Do not prompt, and use default of all prompts | (none) | @@ -230,3 +250,15 @@ Creates packaged layout, registers the Application, and launches the packaged ap | `--symbols` | Download symbols from Microsoft Symbol Server for richer native crash analysis. Only used with --debug-output. First run downloads symbols and caches them locally; subsequent runs use the cache. | (none) | | `--unregister-on-exit` | Unregister the development package after the application exits. Only removes packages registered in development mode. | (none) | | `--with-alias` | Launch the app using its execution alias instead of AUMID activation. The app runs in the current terminal with inherited stdin/stdout/stderr. Requires a uap5:ExecutionAlias in the manifest. Use "winapp manifest add-alias" to add an execution alias to the manifest. | (none) | + +### `winapp unregister` + +Unregisters a sideloaded development package. Only removes packages registered in development mode (e.g., via 'winapp run' or 'create-debug-identity'). + +#### Options + +| Option | Description | Default | +|--------|-------------|---------| +| `--force` | Skip the install-location directory check and unregister even if the package was registered from a different project tree | (none) | +| `--json` | Format output as JSON | (none) | +| `--manifest` | Path to the Package.appxmanifest (default: auto-detect from current directory) | (none) | diff --git a/.gitignore b/.gitignore index d962b820..376ff217 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Test working directory test-wd/ +# Local Claude / Copilot CLI agent permissions (per-machine, never committed) +.claude/ + # Logs logs *.log diff --git a/README.md b/README.md index 21755540..6937ffe4 100644 --- a/README.md +++ b/README.md @@ -226,6 +226,7 @@ See also: [Debugging Guide](./docs/debugging.md) — choosing between `winapp ru **Node.js/Electron Specific:** +- [`node jsbindings add`](./docs/usage.md#node-add-jsbindings) - Add typed JS/TypeScript WinRT bindings to an existing workspace - [`node create-addon`](./docs/usage.md#node-create-addon) - Generate native C# or C++ addons - [`node add-electron-debug-identity`](./docs/usage.md#node-add-electron-debug-identity) - Add identity to Electron processes - [`node clear-electron-debug-identity`](./docs/usage.md#node-clear-electron-debug-identity) - Remove identity from Electron processes diff --git a/docs/cli-schema.json b/docs/cli-schema.json index a0d242ba..506c67bd 100644 --- a/docs/cli-schema.json +++ b/docs/cli-schema.json @@ -683,6 +683,58 @@ "required": false, "recursive": false }, + "--js-bindings": { + "description": "Generate JS/TS bindings via dynwinrt-codegen on top of the standard init flow. Adds a 'jsBindings:' block to winapp.yaml so the binding generator runs as part of init/restore. Only available when invoked via the @microsoft/winappcli npm package (npx winapp init --js-bindings).", + "hidden": false, + "valueType": "System.Boolean", + "hasDefaultValue": true, + "defaultValue": false, + "arity": { + "minimum": 0, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--js-bindings-ai": { + "description": "Generate bindings for the 'ai' slice of the SDK. Implies --js-bindings (no need to pass it separately). For a custom slice that no preset covers, edit winapp.yaml and write your own packages: list under jsBindings. Known presets: ai.", + "hidden": false, + "valueType": "System.Boolean", + "hasDefaultValue": true, + "defaultValue": false, + "arity": { + "minimum": 0, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--js-bindings-lang": { + "description": "Override the JS bindings language. Currently only 'js' is supported (which emits both .js and .d.ts). Reserved for forward-compat; see --js-bindings-output for activation rules.", + "hidden": false, + "helpName": "js", + "valueType": "System.String", + "hasDefaultValue": false, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--js-bindings-output": { + "description": "Override the output directory for generated JS/TS bindings (relative to workspace, default 'bindings/winrt'). Only takes effect together with --js-bindings on a fresh init; ignored on re-init when winapp.yaml already declares jsBindings:.", + "hidden": false, + "helpName": "PATH", + "valueType": "System.String", + "hasDefaultValue": false, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, "--no-gitignore": { "description": "Don't update .gitignore file", "hidden": false, @@ -1070,6 +1122,202 @@ } } }, + "node": { + "description": "Node.js / Electron-specific winapp commands. Only available when invoked via the @microsoft/winappcli npm package (npx winapp node ...).", + "hidden": false, + "subcommands": { + "jsbindings": { + "description": "Manage JS/TS WinRT bindings for an existing workspace. 'add' mutates winapp.yaml + runs codegen; 'generate' just runs codegen against the existing yaml. Only available via the @microsoft/winappcli npm package.", + "hidden": false, + "aliases": [ + "js-bindings" + ], + "subcommands": { + "add": { + "description": "Add a jsBindings: block to winapp.yaml and run codegen. Requires winapp.yaml (run 'winapp init' first). Never modifies the packages: section or installs SDK packages — codegen runs against the workspace's already-restored packages. Refuses to clobber an existing jsBindings: block unless --force is passed. Only available when invoked via the @microsoft/winappcli npm package (npx winapp node jsbindings add).", + "hidden": false, + "arguments": { + "base-directory": { + "description": "Base/root directory for the winapp workspace (default: current directory)", + "order": 0, + "hidden": false, + "valueType": "System.IO.DirectoryInfo", + "hasDefaultValue": false, + "arity": { + "minimum": 0, + "maximum": 1 + } + } + }, + "options": { + "--ai": { + "description": "Generate bindings for the 'ai' slice of the SDK only. For a custom slice that no preset covers, edit winapp.yaml's jsBindings.packages after adding. Known presets: ai.", + "hidden": false, + "valueType": "System.Boolean", + "hasDefaultValue": true, + "defaultValue": false, + "arity": { + "minimum": 0, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--config-dir": { + "description": "Directory containing winapp.yaml (default: base-directory)", + "hidden": false, + "valueType": "System.IO.DirectoryInfo", + "hasDefaultValue": false, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--force": { + "description": "Patch an existing jsBindings: block without prompting. Overwrites only output and (when a preset like --ai is supplied) the packages list; all other fields are preserved. Without --force the command refuses to clobber a pre-existing block (interactive: prompts; non-interactive: errors).", + "hidden": false, + "valueType": "System.Boolean", + "hasDefaultValue": true, + "defaultValue": false, + "arity": { + "minimum": 0, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--output": { + "description": "Output directory for generated JS/TS bindings (relative to workspace, default 'bindings/winrt'). Persisted to winapp.yaml's jsBindings.output field.", + "hidden": false, + "helpName": "PATH", + "valueType": "System.String", + "hasDefaultValue": false, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--quiet": { + "description": "Suppress progress messages", + "hidden": false, + "aliases": [ + "-q" + ], + "valueType": "System.Boolean", + "hasDefaultValue": true, + "defaultValue": false, + "arity": { + "minimum": 0, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--use-defaults": { + "description": "Do not prompt. When jsBindings: already exists in winapp.yaml, preserve it and exit 0 (idempotent). Use --force instead if you want the existing block patched non-interactively.", + "hidden": false, + "aliases": [ + "--no-prompt" + ], + "valueType": "System.Boolean", + "hasDefaultValue": true, + "defaultValue": false, + "arity": { + "minimum": 0, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--verbose": { + "description": "Enable verbose output", + "hidden": false, + "aliases": [ + "-v" + ], + "valueType": "System.Boolean", + "hasDefaultValue": true, + "defaultValue": false, + "arity": { + "minimum": 0, + "maximum": 1 + }, + "required": false, + "recursive": false + } + } + }, + "generate": { + "description": "Re-run dynwinrt-codegen against the existing jsBindings: block in winapp.yaml. Does NOT modify the yaml — for that, use 'node jsbindings add'. Errors if no jsBindings: block is declared. Only available when invoked via the @microsoft/winappcli npm package (npx winapp node jsbindings generate).", + "hidden": false, + "arguments": { + "base-directory": { + "description": "Base/root directory for the winapp workspace (default: current directory)", + "order": 0, + "hidden": false, + "valueType": "System.IO.DirectoryInfo", + "hasDefaultValue": false, + "arity": { + "minimum": 0, + "maximum": 1 + } + } + }, + "options": { + "--config-dir": { + "description": "Directory containing winapp.yaml (default: base-directory)", + "hidden": false, + "valueType": "System.IO.DirectoryInfo", + "hasDefaultValue": false, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--quiet": { + "description": "Suppress progress messages", + "hidden": false, + "aliases": [ + "-q" + ], + "valueType": "System.Boolean", + "hasDefaultValue": true, + "defaultValue": false, + "arity": { + "minimum": 0, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--verbose": { + "description": "Enable verbose output", + "hidden": false, + "aliases": [ + "-v" + ], + "valueType": "System.Boolean", + "hasDefaultValue": true, + "defaultValue": false, + "arity": { + "minimum": 0, + "maximum": 1 + }, + "required": false, + "recursive": false + } + } + } + } + } + } + }, "package": { "description": "Create MSIX installer from your built app. Run after building your app. A manifest (Package.appxmanifest or appxmanifest.xml) is required for packaging - it must be in current working directory, passed as --manifest or be in the input folder. Use --cert devcert.pfx to sign for testing. Example: winapp package ./dist --manifest Package.appxmanifest --cert ./devcert.pfx", "hidden": false, diff --git a/docs/fragments/skills/winapp-cli/frameworks.md b/docs/fragments/skills/winapp-cli/frameworks.md index 97584540..e94ee8f6 100644 --- a/docs/fragments/skills/winapp-cli/frameworks.md +++ b/docs/fragments/skills/winapp-cli/frameworks.md @@ -25,16 +25,22 @@ Use the **npm package** (`@Microsoft/WinAppCli`), **not** the standalone CLI. Th - The native winapp CLI binary bundled inside `node_modules` - A Node.js SDK with helpers for creating native C#/C++ addons - Electron-specific commands under `npx winapp node` +- `node jsbindings add` — generates typed JS/TypeScript WinRT wrappers via dynwinrt (no native build required) Quick start: ```powershell npm install --save-dev @microsoft/winappcli -npx winapp init --use-defaults -npx winapp node create-addon --template cs # create a C# native addon +npx winapp init --use-defaults --js-bindings-ai # init + generate typed AI bindings in bindings/winrt/ +# (or, if you already initialized the workspace:) +npx winapp node jsbindings add --ai # layer JS bindings onto an existing workspace +npx winapp node create-addon --template cs # create a C# native addon (for stuff dynwinrt can't drive) npx winapp node add-electron-debug-identity # register identity for debugging ``` +The `--js-bindings*` flags (and the `node jsbindings add` sub-command) are **npm-only** — they require invocation via the `@microsoft/winappcli` npm package because they pull in `@microsoft/dynwinrt-codegen` as a transitive dep. The winget / standalone install will reject these surfaces with a clear error. + Additional Electron guides: +- [JS bindings reference](https://github.com/microsoft/WinAppCli/blob/main/docs/js-bindings.md) — full `jsBindings:` yaml schema, presets, per-package classification, lockfile - [Packaging guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/packaging.md) - [C++ notification addon guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/cpp-notification-addon.md) - [WinML addon guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/winml-addon.md) diff --git a/docs/fragments/skills/winapp-cli/setup.md b/docs/fragments/skills/winapp-cli/setup.md index b4101d45..27bd1748 100644 --- a/docs/fragments/skills/winapp-cli/setup.md +++ b/docs/fragments/skills/winapp-cli/setup.md @@ -47,6 +47,22 @@ winapp init --use-defaults --setup-sdks none winapp init --use-defaults --setup-sdks preview ``` +### Add JS/TS bindings for Node / Electron apps (npm only) + +When invoked via the `@microsoft/winappcli` npm package, you can generate +typed JS/TS bindings for WinRT APIs alongside the standard init: + +```powershell +# Initialize with the AI slice of the SDK pre-wired. +npx winapp init --use-defaults --js-bindings-ai + +# Or layer bindings onto an already-initialized workspace. +npx winapp node jsbindings add --ai +``` + +Generated files land under `bindings/winrt/` and `@microsoft/dynwinrt` is +added to your `package.json` dependencies so production installs include it. + After `init`, your project will contain: - `Package.appxmanifest` — package identity and capabilities - `Assets/` — default app icons (Square44x44Logo, Square150x150Logo, etc.) diff --git a/docs/guides/electron/index.md b/docs/guides/electron/index.md index b8d33581..7c2d4d0d 100644 --- a/docs/guides/electron/index.md +++ b/docs/guides/electron/index.md @@ -34,21 +34,29 @@ First, you'll set up your development environment with the necessary tools and S [Get Started with Setup →](setup.md) -### 2. Creating a Native Addon +### 2. Calling Windows APIs -Next, you'll create a native addon that calls Windows APIs. Choose one of the following guides: +Next, choose how to call Windows APIs from your Electron app: -#### Option A: [Creating a C++ Notification Addon](cpp-notification-addon.md) +#### Option A: [JS/TypeScript bindings via dynwinrt](../../js-bindings.md) ✨ *new* + +The simplest path — typed JS/TypeScript wrappers generated from `.winmd` metadata, no native build step required from your Electron project. One command (`npx winapp node jsbindings add --ai`) drops a `bindings/winrt/` directory next to your sources; you `import { ChatClient } from './bindings/winrt'` and call WinRT directly. Bindings are typed at compile time but use `dynwinrt`'s libffi runtime to invoke methods at runtime, so no MSBuild / `node-gyp` step is involved. + +[Add JS bindings →](../../js-bindings.md) + +> Native addons (Options B–D below) are still the right choice when you need C++/C# code paths — for instance, to encapsulate a stateful service or to use APIs `dynwinrt` doesn't yet drive (XAML / DispatcherQueue). For data-style WinRT APIs, jsBindings is the easier on-ramp. + +#### Option B: [Creating a C++ Notification Addon](cpp-notification-addon.md) Learn how to create a C++ addon that calls the Windows App SDK notification APIs. This is a great starting point for understanding native addons before diving into more complex scenarios. [Create a C++ Notification Addon →](cpp-notification-addon.md) -#### Option B: [Creating a Phi Silica Addon](phi-silica-addon.md) +#### Option C: [Creating a Phi Silica Addon](phi-silica-addon.md) Learn how to create a C# addon that uses the Phi Silica AI model to summarize text on-device. Phi Silica is a small language model that runs locally on Windows 11 devices with NPUs. [Create a Phi Silica Addon →](phi-silica-addon.md) -#### Option C: [Creating a WinML Addon](winml-addon.md) +#### Option D: [Creating a WinML Addon](winml-addon.md) Learn how to create a C# addon that uses Windows Machine Learning (WinML) to run custom ONNX models for image classification, object detection, and more. [Create a WinML Addon →](winml-addon.md) @@ -68,6 +76,7 @@ Finally, you'll package your app as an MSIX for distribution. This includes: | Phase | Guide | What You'll Learn | |-------|-------|-------------------| | 1️⃣ | [Setup](setup.md) | Install tools, initialize SDKs, configure build pipeline | +| 2️⃣ | [JS bindings (dynwinrt)](../../js-bindings.md) | Generate typed JS/TS WinRT wrappers, no native build step | | 2️⃣ | [C++ Notification Addon](cpp-notification-addon.md) | Create C++ addon, call notification APIs, test with debug identity | | 2️⃣ | [Phi Silica Addon](phi-silica-addon.md) | Create C# addon, call AI APIs, test with debug identity | | 2️⃣ | [WinML Addon](winml-addon.md) | Create C# addon, call WinML APIs, run ONNX models, integrate ML | diff --git a/docs/js-bindings.md b/docs/js-bindings.md new file mode 100644 index 00000000..115e3792 --- /dev/null +++ b/docs/js-bindings.md @@ -0,0 +1,431 @@ +# JS / TypeScript bindings for WinRT (`jsBindings` feature) + +`winapp` can generate typed JavaScript + TypeScript wrappers for Windows Runtime APIs as part of the standard `init` / `restore` flow, or layered onto an existing workspace via the `node jsbindings add` sub-command. The generator runs on top of [dynwinrt](https://github.com/microsoft/dynwinrt) — a runtime FFI bridge that calls WinRT methods via `.winmd` metadata, so the produced bindings are **typed at compile time** but call WinRT **dynamically at runtime** (no native build step required from your project). + +This document covers the user-facing CLI (both `init --js-bindings*` and `node jsbindings add`), the `winapp.yaml` schema, recipes for the common scenarios, and a brief description of what happens under the hood. It reflects the current state of the feature including the v2.0 codegen-owned input refactor. + +> **Availability** — the `--js-bindings*` flags and the `node jsbindings add` sub-command are gated behind invocation via the `@microsoft/winappcli` npm package (i.e. `npx winapp …`). Running `winapp` from a winget / standalone install will reject these surfaces with a clear error message, because the JS-binding generator (`@microsoft/dynwinrt-codegen`) and the runtime (`@microsoft/dynwinrt`) ship as npm dependencies. + +--- + +## Quick start + +The fastest path to "I want to call the WinAppSDK AI APIs from my Node app": + +```bash +npm i -D @microsoft/winappcli +npx winapp init --use-defaults --js-bindings-ai +npm install # picks up the @microsoft/dynwinrt runtime dep that init injected +``` + +That gives you `bindings/winrt/*.js` + `*.d.ts` for the WinAppSDK AI surface, ready to import: + +```ts +import { LanguageModel } from './bindings/winrt/Microsoft.Windows.AI.Generative.LanguageModel'; +const model = await LanguageModel.createAsync(); +``` + +Already have a `winapp.yaml` and just want to add bindings on top? + +```bash +npx winapp node jsbindings add --ai +``` + +Same end-state, but layered onto an existing workspace — `packages:` is left untouched and only the `jsBindings:` block is added. + +--- + +## Common workflows + +> The yaml snippets below show only the fields each workflow touches. For the complete `jsBindings:` schema (every field, default values, type, composition rules), see [`winapp.yaml` — `jsBindings:` block](#winappyaml--jsbindings-block). + +### 1. Generate bindings for the WinAppSDK AI APIs + +```bash +npx winapp init --use-defaults --js-bindings-ai +``` + +The `ai` preset narrows binding generation to the `Microsoft.WindowsAppSDK.AI` NuGet package. All other installed packages are still restored for the C# / native build, just not turned into JS bindings. + +### 2. Generate bindings for the full WinAppSDK surface + +```bash +npx winapp init --js-bindings +``` + +Without a preset, every installed package's `.winmd` files participate in binding generation (plus any winmds added via `additionalWinmds:`). Convenient for exploration; for a shipping app prefer the `--js-bindings-ai` preset or a hand-curated `packages:` list. + +> XAML namespaces (`Microsoft.UI.Xaml.*`, `Windows.UI.Xaml.*`) are out of scope for dynwinrt — the codegen itself classifies them automatically as resolution-only refs, so no JS gets emitted for them regardless of which packages are in scope. + +### 3. Slice generation by NuGet package + +When the `ai` preset is too narrow but you also don't want bindings for every installed package, edit `winapp.yaml` and list the NuGet package IDs you actually want bindings for: + +```yaml +# winapp.yaml +jsBindings: + output: bindings/winrt + packages: + - Microsoft.WindowsAppSDK.AI # AI APIs + - Microsoft.WindowsAppSDK # full WinAppSDK on top +``` + +Each entry must match a NuGet package ID present under your top-level `packages:` block. Empty / omitted means "all installed packages participate" (the v2 default). + +> v2.0 removed the older namespace-prefix slicing (`includeNamespacePrefixes:` / `excludeNamespacePrefixes:`). Slicing now happens at the package level — coarser, but matches how WinRT metadata is actually shipped. + +### 4. Add your own / a vendor `.winmd` + +```yaml +# winapp.yaml +jsBindings: + output: bindings/winrt + additionalWinmds: + - vendor/MyCompany.Foo.winmd # relative to workspace root + - C:\shared\OtherSdk.winmd # absolute also works +``` + +`additionalWinmds:` files are appended to the codegen input alongside the package-discovered winmds. Use this when you want bindings emitted for the entire vendor file. + +### 5. Cherry-pick a few classes from a giant vendor SDK + +```yaml +jsBindings: + output: bindings/winrt + additionalRefs: # load for resolution only — NO bulk emit + - vendor/BigVendor.SDK.winmd + extraTypes: # explicitly list classes to emit + - namespace: BigVendor.Camera + classes: + - Lens + - Sensor +``` + +This is the right pattern when the vendor ships a 200 MB winmd and you only want two classes. The codegen loads the metadata for type resolution but only emits bindings for `Lens` and `Sensor`. The same pattern works for cherry-picking from system `Windows.*` winmds, which the codegen always treats as refs. + +> If the same path appears in both `additionalWinmds:` and `additionalRefs:`, `additionalWinmds:` wins (emission is the stronger intent). + +### 6. Override the output directory + +```bash +npx winapp init --js-bindings --js-bindings-output src/generated/winrt +``` + +Or via `winapp.yaml`: + +```yaml +jsBindings: + output: src/generated/winrt +``` + +For `node jsbindings add`, use `--output` (the sub-command name already scopes it): + +```bash +npx winapp node jsbindings add --ai --output src/generated/winrt +``` + +### 7. Re-init: opt into jsBindings on an existing workspace + +If you ran `init` without bindings and later want them, prefer the layered `node jsbindings add` flow — it touches **only** the `jsBindings:` block and runs codegen against your already-restored packages, skipping the SDK installation steps entirely: + +```bash +npx winapp node jsbindings add --ai # add the AI preset +npx winapp node jsbindings add # add the full surface (no preset) +``` + +If a `jsBindings:` block already exists, the command refuses by default to avoid clobbering hand edits. Pass `--force` to overwrite without prompting: + +```bash +npx winapp node jsbindings add --ai --force +``` + +Re-running `winapp init --js-bindings` on an existing workspace is also supported (older flow), but it will go through the full restore pipeline; `node jsbindings add` is the recommended way to add bindings to an already-initialized project. + +--- + +## CLI reference + +### `winapp init` — parent flag + +| Flag | Type | Description | +|------|------|-------------| +| `--js-bindings` | bool | Enable jsBindings codegen as part of init/restore. Adds a `jsBindings:` block to `winapp.yaml`. Required to activate any of the sub-options below — except the alias flags, which imply it. | + +### `winapp init` — sub-options (effective only when `--js-bindings` is active) + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--js-bindings-output PATH` | string | `bindings/winrt` | Override the output directory (relative to workspace root). | +| `--js-bindings-lang js` | string | `js` | Target language. `js` emits both `.js` + `.d.ts`. Reserved for forward-compat (`py` exists in `dynwinrt-codegen` but is not yet wired through here). | + +### `winapp init` — preset alias flags (auto-generated, one per preset) + +Each entry in [the preset table](#presets) gets a corresponding bool flag. Alias flags **imply `--js-bindings`** — you don't need to type the parent flag. + +| Flag | Effect | +|------|--------| +| `--js-bindings-ai` | Enable jsBindings + write the `ai` preset's package IDs to `packages:` | + +> Today only the `ai` preset ships. The CLI auto-registers one alias flag per entry in `JsBindingsPresets.KnownPresets`, so adding a future preset is a one-line change with no CLI plumbing. + +### `winapp node jsbindings add` — sub-command + +Layered onto an already-initialized workspace. Requires an existing `winapp.yaml`; never installs SDK packages or rewrites the top-level `packages:` block. The job is to add (or replace, with `--force`) the `jsBindings:` block and run codegen against the workspace's restored packages. + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--config-dir PATH` | path | current dir | Directory containing `winapp.yaml`. | +| `--output PATH` | string | `bindings/winrt` | Output directory for generated `.js` + `.d.ts`. Persisted to `jsBindings.output`. | +| `--force` | bool | `false` | Replace an existing `jsBindings:` block without prompting. | +| `--ai` | bool | `false` | Generate bindings for the `ai` preset only (writes its package IDs to `packages:`). One auto-registered flag per entry in `JsBindingsPresets.KnownPresets`. | + +The first positional argument is the workspace base directory (defaults to the current directory). + +--- + +## `winapp.yaml` — `jsBindings:` block + +Full schema with every field shown explicitly: + +```yaml +packages: + - name: Microsoft.WindowsAppSDK + version: 1.8.39 + - name: Microsoft.WindowsAppSDK.AI + version: 0.4.250712-experimental2 + +jsBindings: + # Target language — currently only 'js' (emits both .js and .d.ts). + # 'py' is supported in the underlying codegen but not yet exposed here. + lang: js + + # Output directory for generated .js + .d.ts (relative to workspace root). + output: bindings/winrt + + # NuGet package IDs to scope binding generation to. When non-empty, only + # .winmd files from these packages flow into the codegen (everything else + # under the top-level `packages:` block is still installed for the C# / + # native build, just not turned into JS bindings). Each entry must match + # a package ID present in the top-level `packages:` block. + # When empty / omitted, every installed package participates. + packages: + - Microsoft.WindowsAppSDK.AI + + # Extra .winmd files to feed into the codegen alongside package-discovered + # ones. Each entry is bulk-emitted (gets full bindings). + # Paths: relative to workspace root, OR absolute. Missing files = warning. + additionalWinmds: + - vendor/MyCompany.Foo.winmd + - C:\shared\OtherSdk.winmd + + # Like additionalWinmds, but LOAD-ONLY: the metadata is available for + # resolution (and for extraTypes lookups below) but no bulk emit happens. + # Pair with extraTypes to cherry-pick from large vendor SDKs. + additionalRefs: + - vendor/BigVendor.SDK.winmd + + # Per-class explicit picks. Searches across all loaded winmds (package + + # additionalWinmds + additionalRefs + system Windows.*). Useful for grabbing + # one or two classes from a winmd you don't want fully emitted. + extraTypes: + - namespace: BigVendor.Camera + classes: + - Lens + - Sensor + + # ── Per-package classification overrides (v2.3+) ────────────────────── + # Layered on top of the built-in default policy (WinUI = skip, + # InteractiveExperiences = ref-only). Useful when MS introduces a new XAML + # package or you want to force-emit a normally-denylisted one. + + # Force-skip: drop entirely, no .js emit, not loaded as ref either. + skipPackages: + - Some.New.WinUI.Package + + # Force-ref-only: load for type resolution (--ref channel) but no .js emit. + refOnlyPackages: + - Vendor.PrimitiveTypes + + # Force-emit: overrides default skip / ref-only / user skip / user ref-only. + # Use to opt back in to a denylisted package for experimentation. + emitPackages: + - Microsoft.WindowsAppSDK.WinUI +``` + +### Field defaults at a glance + +| Field | Default | Type | +|-------|---------|------| +| `lang` | `js` | string | +| `output` | `bindings/winrt` | string | +| `packages` | `[]` (= all installed packages) | list of NuGet IDs | +| `additionalWinmds` | `[]` | list of paths | +| `additionalRefs` | `[]` | list of paths | +| `extraTypes` | `[]` | list of `{namespace, classes[]}` | +| `skipPackages` | `[]` | list of NuGet IDs | +| `refOnlyPackages` | `[]` | list of NuGet IDs | +| `emitPackages` | `[]` | list of NuGet IDs | + +### Composition rules (when multiple lists overlap) + +The codegen applies these rules in order: + +1. **Package scope** — if `packages:` is non-empty, only winmds inside those NuGet packages are taken from the package set; otherwise every installed package's winmds are taken. (Top-level `packages:` is the source of truth for what's installed; `jsBindings.packages` only filters which subset participates in JS-binding generation.) +2. **Per-package classification** — each in-scope package is classified into `emit` / `refOnly` / `skip` using the precedence:
**user `emitPackages` ⟶ default-skip ∪ user `skipPackages` ⟶ default-ref-only ∪ user `refOnlyPackages` ⟶ emit**.
Skip drops the winmd; ref-only routes it through `--ref`; emit produces JS bindings. +3. `additionalWinmds:` and `additionalRefs:` paths are appended to the codegen input. If a file is in both lists, `additionalWinmds:` wins. +4. **Auto-classification by codegen** — `Windows.*` system winmds (and any other namespace the codegen treats as a foundation namespace) are loaded as resolution-only refs even when you list them under `additionalWinmds:`. They will not produce JS files in bulk mode; use `extraTypes:` to pull individual classes out. +5. `extraTypes:` runs as a separate pass after the bulk pass — it can pull classes out of any loaded winmd (refs included). + +--- + +## Presets + +Presets are named bundles of NuGet **package IDs** that get written into the `jsBindings.packages:` list. Today only one preset ships — `ai` — because that's the use case this feature was built for: a one-flag path to the WinAppSDK AI APIs. For anything else, edit `winapp.yaml` directly (see [workflow #3](#3-slice-generation-by-nuget-package)). + +| Preset | `init` flag | `node jsbindings add` flag | Package IDs | Notes | +|--------|------------|------------------------|-------------|-------| +| `ai` | `--js-bindings-ai` | `--ai` | `Microsoft.WindowsAppSDK.AI` | Single-package preset; the codegen handles foundation namespaces (`Microsoft.Foundation`, `Windows.*`) automatically as refs. | + +To add a new preset, edit `JsBindingsPresets.KnownPresets` in `WinApp.Cli/Services/JsBindingsPresets.cs`. Both the `init` alias flag and the matching `node jsbindings add` flag are auto-registered from this dictionary at startup — no other code changes required. + +--- + +## Runtime dependency injection + +When `init --js-bindings*` (or `node jsbindings add`) runs for the first time on a workspace, the CLI: + +1. Detects your project's package manager from the `packageManager:` field in `package.json`, then falls back to lockfile sniffing (`pnpm-lock.yaml` → pnpm, `yarn.lock` → yarn, `bun.lockb` → bun, `package-lock.json` → npm). Defaults to **npm** if no signal exists. +2. Adds `@microsoft/dynwinrt` to your `package.json` `dependencies` (production dep, NOT devDep) — your generated bindings `import` from it at module load, so it must ship in your installed app. +3. Prints a PM-aware install hint (`npm install` / `pnpm install` / `yarn install` / `bun install`) so you know what to run next. + +Supported package managers: **npm, pnpm, yarn, bun**. + +> Why production not devDep? `@microsoft/dynwinrt` provides the runtime FFI bridge — without it, your generated `bindings/winrt/*.js` files fail to load at runtime. It's not a build-only tool. + +--- + +## How it works under the hood + +``` + ┌─────────────────────┐ + │ winapp.yaml │ — packages: + jsBindings: blocks + └──────────┬──────────┘ + │ (init / restore / node jsbindings add) + ▼ + ┌──────────────────────────────────────────┐ + │ WorkspaceSetupService │ + │ • restore NuGet packages (init/restore) │ + │ • discover .winmd files in installed │ + │ packages, scoped by │ + │ jsBindings.packages if set │ + │ • resolve additionalWinmds / │ + │ additionalRefs paths │ + └──────────┬───────────────────────────────┘ + │ + ▼ + ┌──────────────────────────────────────────┐ + │ DynWinrtCodegenService │ + │ • partition winmds: emit / ref-only / │ + │ skip (per JsBindingsPresets policy) │ + │ • safety-check output dir │ + │ (.dynwinrt-managed marker) │ + │ • spawn @microsoft/dynwinrt-codegen │ + │ --winmd "p1;p2;..." --ref "r1;..." │ + │ • write .dynwinrt-managed marker │ + └──────────┬───────────────────────────────┘ + │ + ▼ + ┌──────────────────────────────────────────┐ + │ @microsoft/dynwinrt-codegen │ + │ • loads emit winmds + ref winmds │ + │ • auto-classifies Windows.* as │ + │ resolution-only refs │ + │ • generates .js + .d.ts │ → bindings/winrt/..{js,d.ts} + └──────────────────────────────────────────┘ + │ (at app runtime) + ▼ + ┌──────────────────────────────────────────┐ + │ @microsoft/dynwinrt │ (production dep injected into your package.json) + │ • libffi-backed dynamic invocation │ + │ • COM marshaling, async, delegates │ + └──────────────────────────────────────────┘ +``` + +### Per-package winmd categorization + +Some WinAppSDK packages ship `.winmd` files that dynwinrt cannot drive at runtime (XAML composables, UI Composition, DispatcherQueue). To keep the generated tree usable, winapp applies a **package-level policy** before handing winmds to the codegen: + +| Package | Category | Why | +|---------|----------|-----| +| `Microsoft.WindowsAppSDK.WinUI` | **Skip** | Pure XAML composables — `Button`, `Page`, `Application` etc. dynwinrt has no way to host. | +| `Microsoft.WindowsAppSDK.InteractiveExperiences` | **Ref-only** | Ships `Microsoft.UI.WindowId`, `Microsoft.Graphics.PointInt32`, `Microsoft.UI.Color` and other primitive types widely referenced by Foundation/Storage/Notifications APIs — must stay loaded for type resolution, but its own runtime classes are XAML/Composition types winapp cannot drive. | +| Everything else | **Emit** | Bulk-generate JS bindings (codegen still auto-classifies `Windows.*` as refs internally). | + +This split happens in `JsBindingsPresets.PartitionByPackageCategory`. Skipped winmds aren't passed to the codegen at all; ref-only winmds flow through the codegen `--ref` channel. + +**Escape hatch**: if you need the contents of a Skip/Ref-only package (vendor fork, experimentation), list its winmd files explicitly under `jsBindings.additionalWinmds:` — those flow through the user-additional channel and bypass the policy above. + +### The `.dynwinrt-managed` marker and `winmds.lock.json` + +After a successful generation winapp writes `/.dynwinrt-managed` into the output directory. Its presence is the **only** signal winapp uses to know that a non-empty output directory is safe to wipe before the next codegen run. **Never delete this file by hand**, and never put files you care about under the codegen output directory — the next `restore` / `node jsbindings add` will wipe anything other than the marker. (If the directory is non-empty and the marker is missing, winapp aborts rather than risk overwriting hand-written code.) + +In addition, `winapp restore` writes `.winapp/winmds.lock.json` — a human-readable snapshot of every NuGet package the restore resolved, with its version, the per-package winmd discovery results, and the `JsBindingsPresets` category (`emit` / `refOnly` / `skip`). The lockfile is purely an **optimization + audit trail**: + +- `winapp node jsbindings add` reads it to skip the NuGet `.nuspec` HTTP roundtrip + cache re-glob (typically reduces `node jsbindings add` from ~3s to ~200ms in offline / poor-network conditions). +- When the lockfile is missing or its schema doesn't match, `node jsbindings add` transparently falls back to live discovery — no functional dependency on the file. +- Useful to share when reporting bugs: it tells the maintainer exactly what got resolved without us needing to repro your NuGet feed setup. + +**Staleness checks** (v2.3): the lockfile records a SHA-256 of the top-level `packages:` block, and `node jsbindings add` rejects the fast path when: + +1. **yaml drift** — current `packages:` hash differs from the one recorded → user edited yaml since restore. +2. **stale paths** — any winmd path recorded in the lockfile no longer exists on disk → NuGet cache was cleared since restore. + +In both cases `node jsbindings add` falls back to live discovery and tells the user to consider re-running `winapp restore` (which rewrites the lockfile). The fallback path also re-runs the per-package classification (`skipPackages` / `refOnlyPackages` / `emitPackages` overrides take effect immediately — no restore required). + +**Write atomicity**: lockfile writes go through a per-call `.tmp.` sibling that's renamed into place on completion, so concurrent readers always see a consistent (old or new) file, never a torn write. + +This contract is what lets the codegen own all metadata-classification logic (refs vs bulk, `Windows.*` defaults, etc.) without winapp having to maintain a parallel C# implementation of the same rules. + +--- + +## Troubleshooting + +| Symptom | Cause / fix | +|---------|-------------| +| `Error: --js-bindings requires the @microsoft/winappcli npm package` | You ran `winapp` from a winget / standalone install. JS-binding codegen ships as an npm transitive dep — install via `npm i -D @microsoft/winappcli` and call as `npx winapp …`. | +| `bindings/winrt/` is empty after restore | Most likely your `packages:` slice is too narrow, or matches no installed package. Check the debug log (`-v debug`) for the `winmd partition: emit=… ref-only=… skipped=…` line to see what got passed to the codegen. | +| Cannot find a class you expect | The codegen auto-classifies `Windows.*` (and similar foundation namespaces) as refs and does not bulk-emit them. Use `extraTypes:` to pull individual classes out: `{ namespace: 'Windows.Foundation', classes: ['Uri'] }`. | +| `winapp` refuses to write into the output directory | The output directory is non-empty and lacks a `.dynwinrt-managed` marker — winapp won't wipe it because it might contain hand-written code. Either point `output:` somewhere else, or delete the directory yourself if you're sure. | +| Imports from `@microsoft/dynwinrt` fail at app runtime | Make sure you ran your package manager's install command after `init` / `node jsbindings add` (so the auto-injected production dep actually downloads). The CLI prints the right command for your PM in the output. | +| Vendor winmd not found | `additionalWinmds:` / `additionalRefs:` paths are workspace-relative or absolute. Missing files print a warning and are skipped (so a stale entry doesn't break a working restore) — re-check the path. | +| `--js-bindings-output / --js-bindings-lang have no effect without --js-bindings; ignoring.` | You passed a sub-option without `--js-bindings`. Either add `--js-bindings`, or use a `--js-bindings-{preset}` alias flag (which implies it). | +| `node jsbindings add` errors with "jsBindings: already present" | Pass `--force` to replace the existing block without prompting; without it the command refuses to clobber hand-edited config. | + +--- + +## Changelog (feature evolution) + +The feature has shipped in incremental waves; user-visible additions: + +| Version | Headline addition | +|---------|-------------------| +| **v1.0** | Manifest-driven codegen; `init --js-bindings` parent flag; `bindings.manifest.json` written under `.winapp/codegen/`. | +| **v1.1** | XAML namespaces (`Microsoft.UI.Xaml`, `Windows.UI.Xaml`) excluded by default — out of scope for dynwinrt. | +| **v1.2** | `@microsoft/dynwinrt` auto-injected as a production dep in user `package.json`; PM-aware install hint (npm / pnpm / yarn / bun). | +| **v1.4** | `--js-bindings-output` / `--js-bindings-lang` CLI flags; `additionalWinmds:` and `includeNamespacePrefixes:` yaml fields; presets (ai / webview / widgets / appnotifications); re-init UX. | +| **v1.5** | `additionalRefs:` yaml field — load winmds for resolution only, pair with `extraTypes:` to cherry-pick classes from large vendor SDKs without bulk-emitting. | +| **v1.6** | `--js-bindings-{preset}` shorthand alias flags; imply `--js-bindings`; auto-generated from the `KnownPresets` dictionary so adding a preset auto-exposes a flag. **Removed** the now-redundant `--js-bindings-only` flag — the alias flags fully supersede it. | +| **v1.7** | Trimmed shipped presets down to **`ai` only** (the actual goal of this feature: easy on-ramp to WinAppSDK AI APIs). The `webview` / `widgets` / `appnotifications` presets were removed. The dictionary + auto-alias machinery is preserved so a future curated AI sub-slice can be added with one line. (At the time, users wanting those namespaces were directed to write `includeNamespacePrefixes:` directly — that field has since been removed in v2.0; use `jsBindings.packages:` to slice by NuGet package, or `additionalWinmds:` to hand-pick winmd files.) | +| **v1.8** | New `winapp node jsbindings add` sub-command — layered, non-destructive way to add bindings to an existing workspace without going through the full restore pipeline. Auto-registers one `--` flag per entry in `KnownPresets` (e.g. `--ai`). `--force` to replace an existing block, `--output PATH` to override the output dir. | +| **v2.0** | Codegen-owned input refactor. Replaced the JSON manifest (`.winapp/codegen/bindings.manifest.json`) with direct command-line passing of winmd paths to the codegen (`--winmd "p1;p2;..."` / `--ref "r1;r2;..."`). Removed `excludeNamespacePrefixes:` / `includeNamespacePrefixes:` / `importName:` from `winapp.yaml` — `Windows.*` / XAML classification now happens entirely inside the codegen, and slicing happens at the **NuGet package** level via the new `packages:` field instead of namespace prefixes. The `ai` preset now expands to package IDs (`Microsoft.WindowsAppSDK.AI`) rather than namespace prefixes. A `.dynwinrt-managed` marker file inside the output dir gates safe re-wipes. | +| **v2.1** | Per-package winmd categorization (emit / ref-only / skip) added to `JsBindingsPresets`. The `Microsoft.WindowsAppSDK.WinUI` package is now dropped entirely from JS bindings (pure XAML, unusable at dynwinrt runtime); `Microsoft.WindowsAppSDK.InteractiveExperiences` flows through `--ref` only (its primitive types stay available for type resolution but no bindings are emitted for the XAML/Composition runtime classes it ships). | +| **v2.2** | `.winapp/winmds.lock.json` audit + cache artifact. `winapp restore` records every resolved (package, version, category, winmd paths) tuple to a versioned JSON lockfile; `winapp node jsbindings add` reads it first for a no-HTTP-no-glob fast path. Transparent fallback to live discovery when the file is missing or schema-mismatched, so older workspaces keep working unchanged. | +| **v2.3** | Lockfile gets staleness detection (SHA-256 of yaml `packages:` block + winmd path existence check) and atomic write (tmp + rename) — drift between `restore` and `node jsbindings add` no longer silently uses stale data. New yaml fields `skipPackages` / `refOnlyPackages` / `emitPackages` let users override the built-in per-package classification (`Microsoft.WindowsAppSDK.WinUI` = skip, `.InteractiveExperiences` = ref-only). `node jsbindings add --force` now patches the existing `jsBindings:` block instead of overwriting it from scratch — user-edited `extraTypes:` / `additionalWinmds:` / etc. survive. Changing the `output:` directory wipes the old managed bindings (if `.dynwinrt-managed` marker present). | + +--- + +## See also + +- [`@microsoft/dynwinrt`](https://github.com/microsoft/dynwinrt) — the runtime FFI bridge +- [`@microsoft/dynwinrt-codegen`](https://github.com/microsoft/dynwinrt) — the code-generation tool (lives in the same repo as `dynwinrt`) +- `winapp.yaml` schema reference (top-level): `packages:`, `jsBindings:` diff --git a/docs/npm-usage.md b/docs/npm-usage.md index e9ccc08b..db8a47af 100644 --- a/docs/npm-usage.md +++ b/docs/npm-usage.md @@ -201,6 +201,10 @@ function init(options?: InitOptions): Promise | `configDir` | `string \| undefined` | No | Directory to read/store configuration (default: current directory) | | `configOnly` | `boolean \| undefined` | No | Only handle configuration file operations (create if missing, validate if exists). Skip package installation and other workspace setup steps. | | `ignoreConfig` | `boolean \| undefined` | No | Don't use configuration file for version management | +| `jsBindings` | `boolean \| undefined` | No | Generate JS/TS bindings via dynwinrt-codegen on top of the standard init flow. Adds a 'jsBindings:' block to winapp.yaml so the binding generator runs as part of init/restore. Only available when invoked via the \@microsoft/winappcli npm package (npx winapp init --js-bindings). | +| `jsBindingsAi` | `boolean \| undefined` | No | Generate bindings for the 'ai' slice of the SDK. Implies --js-bindings (no need to pass it separately). For a custom slice that no preset covers, edit winapp.yaml and write your own packages: list under jsBindings. Known presets: ai. | +| `jsBindingsLang` | `string \| undefined` | No | Override the JS bindings language. Currently only 'js' is supported (which emits both .js and .d.ts). Reserved for forward-compat; see --js-bindings-output for activation rules. | +| `jsBindingsOutput` | `string \| undefined` | No | Override the output directory for generated JS/TS bindings (relative to workspace, default 'bindings/winrt'). Only takes effect together with --js-bindings on a fresh init; ignored on re-init when winapp.yaml already declares jsBindings:. | | `noGitignore` | `boolean \| undefined` | No | Don't update .gitignore file | | `setupSdks` | `SdkInstallMode \| undefined` | No | SDK installation mode: 'stable' (default), 'preview', 'experimental', or 'none' (skip SDK installation) | | `useDefaults` | `boolean \| undefined` | No | Do not prompt, and use default of all prompts | @@ -275,6 +279,48 @@ function manifestUpdateAssets(options: ManifestUpdateAssetsOptions): Promise +``` + +**Options:** + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `baseDirectory` | `string \| undefined` | No | Base/root directory for the winapp workspace (default: current directory) | +| `ai` | `boolean \| undefined` | No | Generate bindings for the 'ai' slice of the SDK only. For a custom slice that no preset covers, edit winapp.yaml's jsBindings.packages after adding. Known presets: ai. | +| `configDir` | `string \| undefined` | No | Directory containing winapp.yaml (default: base-directory) | +| `force` | `boolean \| undefined` | No | Patch an existing jsBindings: block without prompting. Overwrites only output and (when a preset like --ai is supplied) the packages list; all other fields are preserved. Without --force the command refuses to clobber a pre-existing block (interactive: prompts; non-interactive: errors). | +| `output` | `string \| undefined` | No | Output directory for generated JS/TS bindings (relative to workspace, default 'bindings/winrt'). Persisted to winapp.yaml's jsBindings.output field. | +| `useDefaults` | `boolean \| undefined` | No | Do not prompt. When jsBindings: already exists in winapp.yaml, preserve it and exit 0 (idempotent). Use --force instead if you want the existing block patched non-interactively. | + +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* + +--- + +### `nodeJsbindingsGenerate()` + +Re-run dynwinrt-codegen against the existing jsBindings: block in winapp.yaml. Does NOT modify the yaml — for that, use 'node jsbindings add'. Errors if no jsBindings: block is declared. Only available when invoked via the \@microsoft/winappcli npm package (npx winapp node jsbindings generate). + +```typescript +function nodeJsbindingsGenerate(options?: NodeJsbindingsGenerateOptions): Promise +``` + +**Options:** + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `baseDirectory` | `string \| undefined` | No | Base/root directory for the winapp workspace (default: current directory) | +| `configDir` | `string \| undefined` | No | Directory containing winapp.yaml (default: base-directory) | + +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* + +--- + ### `packageApp()` Create MSIX installer from your built app. Run after building your app. A manifest (Package.appxmanifest or appxmanifest.xml) is required for packaging - it must be in current working directory, passed as --manifest or be in the input folder. Use --cert devcert.pfx to sign for testing. Example: winapp package ./dist --manifest Package.appxmanifest --cert ./devcert.pfx @@ -336,10 +382,12 @@ function run(options: RunOptions): Promise | Property | Type | Required | Description | |----------|------|----------|-------------| | `inputFolder` | `string` | Yes | Input folder containing the app to run | -| `args` | `string \| undefined` | No | Command-line arguments to pass to the application | +| `appArgs` | `string \| undefined` | No | Arguments to pass to the launched application. Provide after -- (e.g., winapp run . -- --flag value). | +| `args` | `string \| undefined` | No | Command-line arguments to pass to the application. Alternatively, use -- followed by arguments to avoid escaping (e.g., winapp run . -- --flag value). | | `clean` | `boolean \| undefined` | No | Remove the existing package's application data (LocalState, settings, etc.) before re-deploying. By default, application data is preserved across re-deployments. | | `debugOutput` | `boolean \| undefined` | No | Capture OutputDebugString messages and first-chance exceptions from the launched application. Only one debugger can attach to a process at a time, so other debuggers (Visual Studio, VS Code) cannot be used simultaneously. Use --no-launch instead if you need to attach a different debugger. Cannot be combined with --no-launch or --json. | | `detach` | `boolean \| undefined` | No | Launch the application and return immediately without waiting for it to exit. Useful for CI/automation where you need to interact with the app after launch. Prints the PID to stdout (or in JSON with --json). | +| `executable` | `string \| undefined` | No | Path to the executable relative to the input folder. Use to disambiguate when the manifest contains a $targetnametoken$ placeholder and multiple .exe files are present in the input folder. | | `json` | `boolean \| undefined` | No | Format output as JSON | | `manifest` | `string \| undefined` | No | Path to the Package.appxmanifest (default: auto-detect from input folder or current directory) | | `noLaunch` | `boolean \| undefined` | No | Only create the debug identity and register the package without launching the application | @@ -596,7 +644,8 @@ function uiScreenshot(options?: UiScreenshotOptions): Promise |----------|------|----------|-------------| | `selector` | `string \| undefined` | No | Semantic slug (e.g., btn-minimize-d1a0) or text to search by name/automationId | | `app` | `string \| undefined` | No | Target app (process name, window title, or PID). Lists windows if ambiguous. | -| `captureScreen` | `boolean \| undefined` | No | Capture from screen (includes popups/overlays) instead of window rendering. Brings window to foreground first. | +| `captureScreen` | `boolean \| undefined` | No | Capture from screen DC via BitBlt (includes popups/overlays not owned by the target). Implies --focus. | +| `focus` | `boolean \| undefined` | No | Bring the target window to the foreground before capture. Already implied by --capture-screen. | | `json` | `boolean \| undefined` | No | Format output as JSON | | `output` | `string \| undefined` | No | Save output to file path (e.g., screenshot) | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | @@ -1163,6 +1212,10 @@ type ManifestTemplates = "packaged" | "sparse" | `configDir` | `string \| undefined` | No | Directory to read/store configuration (default: current directory) | | `configOnly` | `boolean \| undefined` | No | Only handle configuration file operations (create if missing, validate if exists). Skip package installation and other workspace setup steps. | | `ignoreConfig` | `boolean \| undefined` | No | Don't use configuration file for version management | +| `jsBindings` | `boolean \| undefined` | No | Generate JS/TS bindings via dynwinrt-codegen on top of the standard init flow. Adds a 'jsBindings:' block to winapp.yaml so the binding generator runs as part of init/restore. Only available when invoked via the \@microsoft/winappcli npm package (npx winapp init --js-bindings). | +| `jsBindingsAi` | `boolean \| undefined` | No | Generate bindings for the 'ai' slice of the SDK. Implies --js-bindings (no need to pass it separately). For a custom slice that no preset covers, edit winapp.yaml and write your own packages: list under jsBindings. Known presets: ai. | +| `jsBindingsLang` | `string \| undefined` | No | Override the JS bindings language. Currently only 'js' is supported (which emits both .js and .d.ts). Reserved for forward-compat; see --js-bindings-output for activation rules. | +| `jsBindingsOutput` | `string \| undefined` | No | Override the output directory for generated JS/TS bindings (relative to workspace, default 'bindings/winrt'). Only takes effect together with --js-bindings on a fresh init; ignored on re-init when winapp.yaml already declares jsBindings:. | | `noGitignore` | `boolean \| undefined` | No | Don't update .gitignore file | | `setupSdks` | `SdkInstallMode \| undefined` | No | SDK installation mode: 'stable' (default), 'preview', 'experimental', or 'none' (skip SDK installation) | | `useDefaults` | `boolean \| undefined` | No | Do not prompt, and use default of all prompts | @@ -1209,6 +1262,30 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +### `NodeJsbindingsAddOptions` + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `baseDirectory` | `string \| undefined` | No | Base/root directory for the winapp workspace (default: current directory) | +| `ai` | `boolean \| undefined` | No | Generate bindings for the 'ai' slice of the SDK only. For a custom slice that no preset covers, edit winapp.yaml's jsBindings.packages after adding. Known presets: ai. | +| `configDir` | `string \| undefined` | No | Directory containing winapp.yaml (default: base-directory) | +| `force` | `boolean \| undefined` | No | Patch an existing jsBindings: block without prompting. Overwrites only output and (when a preset like --ai is supplied) the packages list; all other fields are preserved. Without --force the command refuses to clobber a pre-existing block (interactive: prompts; non-interactive: errors). | +| `output` | `string \| undefined` | No | Output directory for generated JS/TS bindings (relative to workspace, default 'bindings/winrt'). Persisted to winapp.yaml's jsBindings.output field. | +| `useDefaults` | `boolean \| undefined` | No | Do not prompt. When jsBindings: already exists in winapp.yaml, preserve it and exit 0 (idempotent). Use --force instead if you want the existing block patched non-interactively. | +| `quiet` | `boolean \| undefined` | No | Suppress progress messages. | +| `verbose` | `boolean \| undefined` | No | Enable verbose output. | +| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | + +### `NodeJsbindingsGenerateOptions` + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `baseDirectory` | `string \| undefined` | No | Base/root directory for the winapp workspace (default: current directory) | +| `configDir` | `string \| undefined` | No | Directory containing winapp.yaml (default: base-directory) | +| `quiet` | `boolean \| undefined` | No | Suppress progress messages. | +| `verbose` | `boolean \| undefined` | No | Enable verbose output. | +| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | + ### `PackageOptions` | Property | Type | Required | Description | @@ -1244,10 +1321,12 @@ type ManifestTemplates = "packaged" | "sparse" | Property | Type | Required | Description | |----------|------|----------|-------------| | `inputFolder` | `string` | Yes | Input folder containing the app to run | -| `args` | `string \| undefined` | No | Command-line arguments to pass to the application | +| `appArgs` | `string \| undefined` | No | Arguments to pass to the launched application. Provide after -- (e.g., winapp run . -- --flag value). | +| `args` | `string \| undefined` | No | Command-line arguments to pass to the application. Alternatively, use -- followed by arguments to avoid escaping (e.g., winapp run . -- --flag value). | | `clean` | `boolean \| undefined` | No | Remove the existing package's application data (LocalState, settings, etc.) before re-deploying. By default, application data is preserved across re-deployments. | | `debugOutput` | `boolean \| undefined` | No | Capture OutputDebugString messages and first-chance exceptions from the launched application. Only one debugger can attach to a process at a time, so other debuggers (Visual Studio, VS Code) cannot be used simultaneously. Use --no-launch instead if you need to attach a different debugger. Cannot be combined with --no-launch or --json. | | `detach` | `boolean \| undefined` | No | Launch the application and return immediately without waiting for it to exit. Useful for CI/automation where you need to interact with the app after launch. Prints the PID to stdout (or in JSON with --json). | +| `executable` | `string \| undefined` | No | Path to the executable relative to the input folder. Use to disambiguate when the manifest contains a $targetnametoken$ placeholder and multiple .exe files are present in the input folder. | | `json` | `boolean \| undefined` | No | Format output as JSON | | `manifest` | `string \| undefined` | No | Path to the Package.appxmanifest (default: auto-detect from input folder or current directory) | | `noLaunch` | `boolean \| undefined` | No | Only create the debug identity and register the package without launching the application | @@ -1396,7 +1475,8 @@ type ManifestTemplates = "packaged" | "sparse" |----------|------|----------|-------------| | `selector` | `string \| undefined` | No | Semantic slug (e.g., btn-minimize-d1a0) or text to search by name/automationId | | `app` | `string \| undefined` | No | Target app (process name, window title, or PID). Lists windows if ambiguous. | -| `captureScreen` | `boolean \| undefined` | No | Capture from screen (includes popups/overlays) instead of window rendering. Brings window to foreground first. | +| `captureScreen` | `boolean \| undefined` | No | Capture from screen DC via BitBlt (includes popups/overlays not owned by the target). Implies --focus. | +| `focus` | `boolean \| undefined` | No | Bring the target window to the foreground before capture. Already implied by --capture-screen. | | `json` | `boolean \| undefined` | No | Format output as JSON | | `output` | `string \| undefined` | No | Save output to file path (e.g., screenshot) | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | diff --git a/docs/usage.md b/docs/usage.md index 418ac7a4..bd208c93 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -36,6 +36,13 @@ winapp init [base-directory] [options] - `--use-defaults`, `--no-prompt` - Do not prompt, and use default of all prompts - `--config-only` - Only handle configuration file operations, skip package installation +**JS/TypeScript bindings flags** (npm-only — require invocation via `npx winapp …`): + +- `--js-bindings` - Enable jsBindings codegen as part of init/restore. Adds a `jsBindings:` block to `winapp.yaml`. See [JS bindings docs](js-bindings.md) for the full feature. +- `--js-bindings-output ` - Override the bindings output dir (default `bindings/winrt`). Only effective with `--js-bindings`. +- `--js-bindings-lang ` - Target language. Currently only `js` is supported (emits both `.js` and `.d.ts`). `py` exists in the underlying codegen but is not yet wired through here — reserved for forward-compat. +- `--js-bindings-ai` - Shorthand for `--js-bindings` + AI preset (writes `Microsoft.WindowsAppSDK.AI` to `jsBindings.packages`). Auto-registered from `JsBindingsPresets.KnownPresets`. + **What it does:** - Creates `winapp.yaml` configuration file (only when SDK packages are managed; skipped with `--setup-sdks none`) @@ -147,6 +154,52 @@ winapp update --setup-sdks experimental --- +### node jsbindings add + +Layer JS/TypeScript bindings (via `dynwinrt-codegen`) onto an already-initialized workspace, **without** rerunning the SDK install pipeline. Requires an existing `winapp.yaml` and previously-restored packages. npm-only — invoke as `npx winapp node jsbindings add`. + +```bash +npx winapp node jsbindings add [base-directory] [options] +``` + +**Arguments:** + +- `base-directory` - Workspace root containing `winapp.yaml` (default: current directory) + +**Options:** + +- `--config-dir ` - Directory containing `winapp.yaml` (default: current directory) +- `--output ` - Output dir for generated `.js` + `.d.ts`, persisted to `jsBindings.output` (default `bindings/winrt`) +- `--force` - Replace an existing `jsBindings:` block without prompting (refuses by default to avoid clobbering hand edits) +- `--ai` - Use the AI preset (writes `Microsoft.WindowsAppSDK.AI` to `jsBindings.packages`). Auto-registered from `JsBindingsPresets.KnownPresets` — one flag per preset. + +**What it does:** + +- Reads `winapp.yaml`, adds (or replaces with `--force`) a `jsBindings:` block from the CLI options +- Discovers winmds via `.winapp/winmds.lock.json` (fast path, written by `restore`) or via NuGet cache walk + transitive-deps expansion (fallback when the lockfile is missing) +- Spawns `@microsoft/dynwinrt-codegen` to emit `.js` + `.d.ts` into the output dir +- Auto-injects `@microsoft/dynwinrt` as a production dep in your `package.json` so generated bindings can `import` it at runtime + +**Examples:** + +```bash +# Add the AI preset to an existing workspace +npx winapp node jsbindings add --ai + +# Add the full surface (no preset) +npx winapp node jsbindings add + +# Replace an existing jsBindings: block +npx winapp node jsbindings add --ai --force + +# Custom output directory +npx winapp node jsbindings add --ai --output src/generated/winrt +``` + +> See [JS bindings docs](js-bindings.md) for the full feature reference, including `winapp.yaml` schema, presets, per-package winmd categorization, and the `winmds.lock.json` audit/cache artifact. + +--- + ### pack Create MSIX packages from prepared application directories. Requires a manifest file (`Package.appxmanifest` preferred, `appxmanifest.xml` also supported) to be present in the target directory, in the current directory, or passed with the `--manifest` option. (run `init` or `manifest generate` to create a manifest) diff --git a/samples/electron/test.Tests.ps1 b/samples/electron/test.Tests.ps1 index 459a0d2b..366c7a3b 100644 --- a/samples/electron/test.Tests.ps1 +++ b/samples/electron/test.Tests.ps1 @@ -123,6 +123,48 @@ Describe "Electron Sample" { Join-Path $script:appDir "Package.appxmanifest" | Should -Exist } + # ── JS bindings smoke (v2.x) ───────────────────────────────────── + # Verify the add jsbindings --ai path end-to-end. Use --ai because + # it's the narrowest preset (~7 winmds → ~65 .js, <5s on hot cache). + + It "Should add JS bindings via 'add jsbindings --ai'" -Skip:$script:skip { + Push-Location $script:appDir + try { + # winapp.cmd via npx sets WINAPP_CLI_CALLER (the command + # refuses without it). + Invoke-WinappCommand -Arguments "add jsbindings --ai --force" + } finally { Pop-Location } + } + + It "Should have generated bindings/winrt/ with the managed marker" -Skip:$script:skip { + $bindingsDir = Join-Path $script:appDir "bindings\winrt" + $bindingsDir | Should -Exist + # Marker proves the staging-then-swap completed. + (Join-Path $bindingsDir ".dynwinrt-managed") | Should -Exist + # AI preset generates around 65 .js files; assert at least a + # handful to catch the "0 files generated" regression. + $jsCount = (Get-ChildItem -Path $bindingsDir -Filter '*.js' -ErrorAction SilentlyContinue).Count + $jsCount | Should -BeGreaterThan 10 -Because "AI preset should generate 60+ JS files" + } + + It "Should inject @microsoft/dynwinrt as a runtime dep in package.json" -Skip:$script:skip { + # Bindings import @microsoft/dynwinrt at load time — must be a + # production dep so `npm ci --omit=dev` doesn't strip it. + $pkgPath = Join-Path $script:appDir "package.json" + $pkg = Get-Content $pkgPath -Raw | ConvertFrom-Json + $pkg.dependencies.'@microsoft/dynwinrt' | Should -Not -BeNullOrEmpty ` + -Because "add jsbindings must auto-inject the runtime dep" + } + + It "Should write a winmds.lock.json under .winapp/" -Skip:$script:skip { + # Seeded by restore (during init); enables the add fast path. + $lockfilePath = Join-Path $script:appDir ".winapp\winmds.lock.json" + $lockfilePath | Should -Exist + $lockfile = Get-Content $lockfilePath -Raw | ConvertFrom-Json + $lockfile.schema | Should -BeGreaterThan 0 -Because "Lockfile should have schema versioning" + $lockfile.packages | Should -Not -BeNullOrEmpty -Because "Lockfile should record discovered packages" + } + It "Should create a C++ native addon" -Skip:$script:skip { Push-Location $script:appDir try { @@ -159,12 +201,9 @@ Describe "Electron Sample" { } It "Should download the Electron binary" -Skip:$script:skip { - # Electron 42+ no longer downloads its binary during `npm install` (see issue #524). - # Trigger the download explicitly so `add-electron-debug-identity` can find electron.exe. - # `install-electron` was added in Electron 42; older versions auto-download via - # postinstall, so the bin is absent and `npx --no-install` exits non-zero. Either - # outcome is fine as long as electron.exe ends up on disk — the Should -Exist below - # is the real assertion. + # Electron 42+ stopped auto-downloading on `npm install`; trigger + # it explicitly. install-electron is the v42 mechanism; exit code + # is ignored — the Should -Exist below is the real assertion. Push-Location $script:appDir try { & npx --no-install install-electron 2>&1 | ForEach-Object { Write-Host $_ } diff --git a/scripts/generate-llm-docs.ps1 b/scripts/generate-llm-docs.ps1 index eb87c8f3..167baf66 100644 --- a/scripts/generate-llm-docs.ps1 +++ b/scripts/generate-llm-docs.ps1 @@ -69,10 +69,24 @@ if ($LASTEXITCODE -ne 0) { exit 1 } -# Join array lines into single string with LF line endings (CLI outputs pretty-printed JSON) -# Ensure exactly one trailing newline for consistency +# Join lines into a single string with LF endings + one trailing newline. $SchemaJson = ($SchemaJsonLines -join "`n").TrimEnd() + "`n" +# Override schema version with version.json (CLI binary may have the +# AssemblyInformationalVersion default "1.0.0"). validate-llm-docs.ps1 +# does the same substitution at compare time. +$VersionJsonPath = Join-Path (Split-Path $PSScriptRoot) "version.json" +if (Test-Path $VersionJsonPath) { + $BaseVersion = (Get-Content $VersionJsonPath | ConvertFrom-Json).version + $SchemaObj = $SchemaJson | ConvertFrom-Json + if ($SchemaObj.version -ne $BaseVersion) { + Write-Host "[DOCS] Overriding schema version '$($SchemaObj.version)' (from CLI binary) with '$BaseVersion' (from version.json)" -ForegroundColor Yellow + $SchemaObj.version = $BaseVersion + $SchemaJson = ($SchemaObj | ConvertTo-Json -Depth 100) -replace "`r`n", "`n" + if (-not $SchemaJson.EndsWith("`n")) { $SchemaJson += "`n" } + } +} + # Save schema JSON with consistent LF line endings [System.IO.File]::WriteAllText($SchemaOutputPath, $SchemaJson, [System.Text.UTF8Encoding]::new($false)) Write-Host "[DOCS] Saved: $SchemaOutputPath" -ForegroundColor Green @@ -95,7 +109,7 @@ $SkillsDir = $SkillsPath # Skill → CLI command mapping for auto-generated options/arguments tables # Each skill maps to one or more CLI commands whose options/arguments should be included $SkillCommandMap = @{ - "setup" = @("init", "restore", "update", "run") + "setup" = @("init", "restore", "update", "run", "add jsbindings", "unregister") "package" = @("package", "create-external-catalog") "identity" = @("create-debug-identity") "signing" = @("cert generate", "cert install", "cert info", "sign") diff --git a/src/winapp-CLI/WinApp.Cli.Tests/AddJsBindingsCommandTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/AddJsBindingsCommandTests.cs new file mode 100644 index 00000000..983a1672 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/AddJsBindingsCommandTests.cs @@ -0,0 +1,452 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.DependencyInjection; +using WinApp.Cli.Commands; +using WinApp.Cli.Models; +using WinApp.Cli.Services; + +namespace WinApp.Cli.Tests; + +// Tests for AddJsBindingsCommand — focus on yaml mutations + npm-shim gate. +// Codegen result is not asserted (no real NuGet cache → exit 1 from "no +// winmds"); yaml is mutated before codegen so state assertions still hold. +// [DoNotParallelize] because tests mutate WINAPP_CLI_CALLER process-wide. +[TestClass] +[DoNotParallelize] +public class AddJsBindingsCommandTests : BaseCommandTests +{ + private string? _savedCaller; + + [TestInitialize] + public void TestSetup() + { + _savedCaller = Environment.GetEnvironmentVariable("WINAPP_CLI_CALLER"); + // Default to the npm-shim caller; tests that need to assert the gate + // override this explicitly. + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); + } + + [TestCleanup] + public void TestTeardown() + { + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", _savedCaller); + } + + // Helper: write a minimal valid winapp.yaml (packages: only) so the + // add command sees a "post-init" workspace. Returns the absolute path. + private async Task WriteMinimalYamlAsync() + { + var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); + await File.WriteAllTextAsync(configPath, + "packages:\n - name: Microsoft.WindowsAppSDK\n version: 1.8.39\n"); + return configPath; + } + + // Helper: write a winapp.yaml that already has a jsBindings: block. + // Used to exercise the "existing block" branches (force / non-force / + // non-interactive). + private async Task WriteYamlWithJsBindingsAsync(string output = "old/output") + { + var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); + await File.WriteAllTextAsync(configPath, + "packages:\n" + + " - name: Microsoft.WindowsAppSDK\n" + + " version: 1.8.39\n" + + "jsBindings:\n" + + $" output: {output}\n" + + " lang: js\n"); + return configPath; + } + + [TestMethod] + public async Task AddJsBindings_WithoutNpmCaller_ExitsWithActionableError() + { + // Same npm-shim gating as InitCommand --js-bindings. + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", null); + + var addCmd = GetRequiredService(); + var args = new[] { _tempDirectory.FullName }; + + var exitCode = await ParseAndInvokeWithCaptureAsync(addCmd, args); + + Assert.AreEqual(1, exitCode, "Non-npm caller must exit 1"); + var stderr = ConsoleStdErr.ToString(); + StringAssert.Contains(stderr, "'node jsbindings add' requires the @microsoft/winappcli npm package", + "Error must name the command and the required package"); + StringAssert.Contains(stderr, "npx winapp node jsbindings add", + "Error must include the recovery invocation"); + + // No yaml mutation should occur — bailed before service ran. + var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); + Assert.IsFalse(File.Exists(configPath), + "winapp.yaml must not be touched when the npm-shim gate fails"); + } + + [TestMethod] + public async Task AddJsBindings_NoYaml_ReturnsErrorWithInitHint() + { + // No init was ever run → there's no winapp.yaml. add jsbindings is a + // layered command and must refuse instead of silently bootstrapping. + var addCmd = GetRequiredService(); + var args = new[] { _tempDirectory.FullName }; + + var exitCode = await ParseAndInvokeWithCaptureAsync(addCmd, args); + + Assert.AreEqual(1, exitCode, "Missing winapp.yaml must surface an error"); + // Failure goes to stderr (via ILogger.LogError); stdout stays clean + // so non-interactive consumers can rely on it for success payloads. + var stderr = ConsoleStdErr.ToString(); + StringAssert.Contains(stderr, "winapp.yaml not found", + "Error must explain the missing yaml precondition (routed to stderr)"); + StringAssert.Contains(stderr, "winapp init", + "Error must point users at the bootstrap command"); + } + + [TestMethod] + public async Task AddJsBindings_FreshWorkspace_AddsJsBindingsBlock() + { + // Yaml exists, no jsBindings block → inject defaults + persist. + var configPath = await WriteMinimalYamlAsync(); + + var addCmd = GetRequiredService(); + var args = new[] { _tempDirectory.FullName }; + + await ParseAndInvokeWithCaptureAsync(addCmd, args); + + var content = await File.ReadAllTextAsync(configPath); + StringAssert.Contains(content, "jsBindings:", + "jsBindings: block must be injected by add jsbindings"); + StringAssert.Contains(content, "lang: js", + "Default lang=js must be persisted"); + StringAssert.Contains(content, "bindings/winrt", + "Default output dir must be persisted"); + StringAssert.Contains(content, "packages:", + "Existing packages section must be preserved (non-destructive)"); + StringAssert.Contains(content, "Microsoft.WindowsAppSDK", + "Pre-existing package pin must survive the add"); + } + + [TestMethod] + public async Task AddJsBindings_WithOutput_PersistsCustomOutputDir() + { + // --output should override the default 'bindings/winrt' and land + // verbatim in the yaml's jsBindings.output field. + var configPath = await WriteMinimalYamlAsync(); + + var addCmd = GetRequiredService(); + var args = new[] { _tempDirectory.FullName, "--output", "src/generated/winrt" }; + + await ParseAndInvokeWithCaptureAsync(addCmd, args); + + var content = await File.ReadAllTextAsync(configPath); + StringAssert.Contains(content, "output: src/generated/winrt", + "--output must override the default and persist to yaml"); + } + + [TestMethod] + public async Task AddJsBindings_AiAlias_PopulatesPackages() + { + // --ai populates jsBindings.packages with the preset's NuGet IDs. + var configPath = await WriteMinimalYamlAsync(); + var aiPackages = JsBindingsPresets.KnownPresets["ai"]; + Assert.IsTrue(aiPackages.Count > 0, "Test precondition: ai preset must declare package IDs"); + + var addCmd = GetRequiredService(); + var args = new[] { _tempDirectory.FullName, "--ai" }; + + await ParseAndInvokeWithCaptureAsync(addCmd, args); + + var content = await File.ReadAllTextAsync(configPath); + StringAssert.Contains(content, "packages:", + "--ai must produce a packages: yaml field under jsBindings"); + foreach (var pkg in aiPackages) + { + StringAssert.Contains(content, pkg, + $"AI preset package id {pkg} must appear in the persisted yaml"); + } + } + + [TestMethod] + public async Task AddJsBindings_ExistingBlockNoForce_NonInteractive_ReturnsError() + { + // Non-interactive runtime → prompt throws → we surface the --force + // hint instead of clobbering. + var configPath = await WriteYamlWithJsBindingsAsync("custom/old"); + + var addCmd = GetRequiredService(); + var args = new[] { _tempDirectory.FullName }; + + var exitCode = await ParseAndInvokeWithCaptureAsync(addCmd, args); + + Assert.AreEqual(1, exitCode, + "Existing block + no --force + non-interactive must exit 1"); + // --force hint goes to stderr via ILogger.LogError (M11 fix). + var stderr = ConsoleStdErr.ToString(); + StringAssert.Contains(stderr, "--force", + "Error must point users at --force to bypass the prompt in CI (routed to stderr)"); + + // Yaml must NOT be mutated — the original output should still be there. + var content = await File.ReadAllTextAsync(configPath); + StringAssert.Contains(content, "custom/old", + "Original jsBindings block must be preserved when the prompt rejects"); + } + + [TestMethod] + public async Task AddJsBindings_ExistingBlockWithForce_ReplacesBlock() + { + // --force bypasses the prompt entirely (silent replace). The new + // block should overwrite the old one with the CLI-supplied output. + var configPath = await WriteYamlWithJsBindingsAsync("custom/old"); + + var addCmd = GetRequiredService(); + var args = new[] { _tempDirectory.FullName, "--force", "--output", "fresh/output" }; + + await ParseAndInvokeWithCaptureAsync(addCmd, args); + + var content = await File.ReadAllTextAsync(configPath); + StringAssert.Contains(content, "fresh/output", + "--force must replace the existing jsBindings block with the new one"); + Assert.IsFalse(content.Contains("custom/old"), + "Old jsBindings block must be gone after --force replace"); + } + + // scripted callers (CI / build steps) need a safe + // non-interactive no-op that preserves an existing jsBindings: block + // without prompting and without overwriting. + [TestMethod] + public async Task AddJsBindings_ExistingBlockWithUseDefaults_PreservesAndExitsZero() + { + var configPath = await WriteYamlWithJsBindingsAsync("custom/old"); + var originalContent = await File.ReadAllTextAsync(configPath); + + var addCmd = GetRequiredService(); + var args = new[] { _tempDirectory.FullName, "--use-defaults", "--output", "should-be-ignored" }; + + var exitCode = await ParseAndInvokeWithCaptureAsync(addCmd, args); + + Assert.AreEqual(0, exitCode, + "--use-defaults must exit 0 (idempotent no-op) when jsBindings already exists."); + + var content = await File.ReadAllTextAsync(configPath); + Assert.AreEqual(originalContent, content, + "File on disk must be byte-identical — --use-defaults preserves, does NOT mutate."); + Assert.IsFalse(content.Contains("should-be-ignored"), + "--output override must be ignored when --use-defaults preserves the existing block."); + } + + [TestMethod] + public async Task AddJsBindings_NoExistingBlockWithUseDefaults_NormalAddFlow() + { + // --use-defaults is a no-op marker for the existing-block case only. + // When there's NO block yet, the command proceeds normally (adds). + await WriteMinimalYamlAsync(); + + var addCmd = GetRequiredService(); + var args = new[] { _tempDirectory.FullName, "--use-defaults", "--output", "bindings/winrt" }; + + // Exit may be 1 (no NuGet cache → "No .winmd files found") OR 0 + // (somehow finds metadata); we assert the yaml mutation, not the + // codegen result. Either way, the yaml MUST have been patched + // because --use-defaults shouldn't short-circuit on a fresh add. + await ParseAndInvokeWithCaptureAsync(addCmd, args); + + var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); + var content = await File.ReadAllTextAsync(configPath); + StringAssert.Contains(content, "jsBindings:", + "Fresh add (no existing block) with --use-defaults must still write the block."); + } + + [TestMethod] + public async Task AddJsBindings_ForceAndUseDefaultsTogether_RejectedAsMutuallyExclusive() + { + await WriteYamlWithJsBindingsAsync("custom/old"); + + var addCmd = GetRequiredService(); + var args = new[] { _tempDirectory.FullName, "--force", "--use-defaults" }; + + var exitCode = await ParseAndInvokeWithCaptureAsync(addCmd, args); + + Assert.AreEqual(1, exitCode); + var stderr = ConsoleStdErr.ToString(); + StringAssert.Contains(stderr, "mutually exclusive", + "Error must call out the conflict so users pick one."); + } + + // interactive overwrite prompt — "No" answer. + [TestMethod] + public async Task AddJsBindings_ExistingBlockNoForce_PromptNo_PreservesYamlAndSkipsCodegen() + { + var configPath = await WriteYamlWithJsBindingsAsync("custom/old"); + var originalContent = await File.ReadAllTextAsync(configPath); + + // Drive the ConfirmationPrompt with "n" + Enter. The default for + // Spectre's ConfirmationPrompt is "Yes", so we have to explicitly + // type N to override. + TestAnsiConsole.Input.PushTextWithEnter("n"); + + var addCmd = GetRequiredService(); + var args = new[] { _tempDirectory.FullName }; + + var exitCode = await ParseAndInvokeWithCaptureAsync(addCmd, args); + + Assert.AreEqual(0, exitCode, + "Prompt 'No' must exit 0 (user chose to preserve)."); + + var content = await File.ReadAllTextAsync(configPath); + Assert.AreEqual(originalContent, content, + "Yaml on disk must be unchanged after prompt 'No'."); + } + + // interactive overwrite prompt — "Yes" answer patches. + [TestMethod] + public async Task AddJsBindings_ExistingBlockNoForce_PromptYes_PatchesYamlAndProceedsToCodegen() + { + var configPath = await WriteYamlWithJsBindingsAsync("custom/old"); + TestAnsiConsole.Input.PushTextWithEnter("y"); + + var addCmd = GetRequiredService(); + var args = new[] { _tempDirectory.FullName, "--output", "fresh/output" }; + + // Exit may be 1 because the test env has no NuGet cache → codegen + // discovery returns 0 winmds → "No .winmd files found" → 1. + // We assert YAML mutation, not codegen result; the YAML patch + // happens BEFORE codegen runs so it's observable either way. + await ParseAndInvokeWithCaptureAsync(addCmd, args); + + var content = await File.ReadAllTextAsync(configPath); + StringAssert.Contains(content, "fresh/output", + "Prompt 'Yes' must patch the existing block with the new output."); + Assert.IsFalse(content.Contains("custom/old"), + "Old output must be gone after 'Yes' patch."); + } + + [TestMethod] + public async Task AddJsBindings_ExistingBlockWithForce_PreservesUserCustomizedFields() + { + // --force is a PATCH (not replace): CLI fields overwrite; everything + // else (extraTypes / additionalWinmds/Refs / skip+refOnly+emit + // overrides) must survive. + var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); + await File.WriteAllTextAsync(configPath, + "packages:\n" + + " - name: Microsoft.WindowsAppSDK\n" + + " version: 1.8.39\n" + + "jsBindings:\n" + + " output: custom/old\n" + + " lang: js\n" + + " packages:\n" + + " - Microsoft.WindowsAppSDK.OldPreset\n" + + " additionalWinmds:\n" + + " - vendor/MyCo.Foo.winmd\n" + + " additionalRefs:\n" + + " - vendor/BigSDK.winmd\n" + + " skipPackages:\n" + + " - Custom.SkipMe.Package\n" + + " refOnlyPackages:\n" + + " - Custom.RefOnlyMe.Package\n" + + " emitPackages:\n" + + " - Microsoft.WindowsAppSDK.WinUI\n" + + " extraTypes:\n" + + " - namespace: Windows.Foundation\n" + + " classes:\n" + + " - Uri\n" + + " - Calendar\n"); + + var addCmd = GetRequiredService(); + // --ai overrides packages: with the AI preset, --output overrides output:; + // every other field above must survive. + var args = new[] { _tempDirectory.FullName, "--force", "--ai", "--output", "fresh/output" }; + + await ParseAndInvokeWithCaptureAsync(addCmd, args); + + var content = await File.ReadAllTextAsync(configPath); + + // CLI-touched fields: replaced. + StringAssert.Contains(content, "fresh/output", + "--output must overwrite jsBindings.output"); + StringAssert.Contains(content, "Microsoft.WindowsAppSDK.AI", + "--ai must overwrite jsBindings.packages with the preset's IDs"); + Assert.IsFalse(content.Contains("custom/old"), "Old output must be gone"); + Assert.IsFalse(content.Contains("OldPreset"), "Old packages list must be gone"); + + // Untouched user fields: preserved. + StringAssert.Contains(content, "vendor/MyCo.Foo.winmd", + "additionalWinmds entries must survive --force patch"); + StringAssert.Contains(content, "vendor/BigSDK.winmd", + "additionalRefs entries must survive --force patch"); + StringAssert.Contains(content, "Custom.SkipMe.Package", + "skipPackages overrides must survive --force patch"); + StringAssert.Contains(content, "Custom.RefOnlyMe.Package", + "refOnlyPackages overrides must survive --force patch"); + StringAssert.Contains(content, "Microsoft.WindowsAppSDK.WinUI", + "emitPackages overrides must survive --force patch"); + StringAssert.Contains(content, "Windows.Foundation", + "extraTypes namespace must survive --force patch"); + StringAssert.Contains(content, "Uri", + "extraTypes classes must survive --force patch"); + StringAssert.Contains(content, "Calendar", + "extraTypes classes must survive --force patch (2nd entry)"); + } + + [TestMethod] + public async Task AddJsBindings_KebabCaseAlias_RoutesToSameHandler() + { + // `node js-bindings add` (kebab-case alias on parent) routes correctly. + var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); + await File.WriteAllTextAsync(configPath, + "packages:\n" + + " - name: Microsoft.WindowsAppSDK\n" + + " version: 1.8.39\n"); + + var rootCmd = GetRequiredService(); + var args = new[] { "node", "js-bindings", "add", _tempDirectory.FullName }; + + var parseResult = rootCmd.Parse(args); + Assert.AreEqual(0, parseResult.Errors.Count, + "Kebab-case alias must parse without errors: " + + string.Join("; ", parseResult.Errors.Select(e => e.Message))); + + Assert.IsInstanceOfType(parseResult.CommandResult.Command, + "`node js-bindings add` must route to AddJsBindingsCommand via the parent alias."); + } + + [TestMethod] + public async Task AddJsBindings_WithConfigDirSeparateFromWorkspace_PatchesIntendedYaml() + { + // --config-dir lets the user point at a different directory containing + // winapp.yaml while keeping the workspace (binding-output anchor) elsewhere. + var configDir = _tempDirectory.CreateSubdirectory("config-dir"); + var workspaceDir = _tempDirectory.CreateSubdirectory("workspace"); + + var configPath = Path.Combine(configDir.FullName, "winapp.yaml"); + await File.WriteAllTextAsync(configPath, + "packages:\n" + + " - name: Microsoft.WindowsAppSDK\n" + + " version: 1.8.39\n"); + + // A decoy yaml in the workspace should NOT be touched. + var decoyPath = Path.Combine(workspaceDir.FullName, "winapp.yaml"); + await File.WriteAllTextAsync(decoyPath, "# decoy — must not be modified\n"); + + var addCmd = GetRequiredService(); + var args = new[] + { + workspaceDir.FullName, + "--config-dir", configDir.FullName, + "--output", "bindings/winrt", + }; + + await ParseAndInvokeWithCaptureAsync(addCmd, args); + + var actualConfig = await File.ReadAllTextAsync(configPath); + StringAssert.Contains(actualConfig, "jsBindings:", + "--config-dir target yaml must be patched"); + + var decoy = await File.ReadAllTextAsync(decoyPath); + StringAssert.Contains(decoy, "# decoy", + "Workspace-directory yaml must NOT be touched when --config-dir is set"); + Assert.IsFalse(decoy.Contains("jsBindings:"), + "Workspace yaml must remain unpatched."); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/AddJsBindingsOrchestrationTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/AddJsBindingsOrchestrationTests.cs new file mode 100644 index 00000000..f6fc2cd1 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/AddJsBindingsOrchestrationTests.cs @@ -0,0 +1,776 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.DependencyInjection; +using WinApp.Cli.Commands; +using WinApp.Cli.Helpers; +using WinApp.Cli.Models; +using WinApp.Cli.Services; +using WinApp.Cli.Tests.TestDoubles; + +namespace WinApp.Cli.Tests; + +// Hermetic orchestration tests for AddJsBindingsAsync. FakeDynWinrtCodegenService +// is injected so the codegen executable is never spawned. Fast-path vs fallback +// is driven by writing (or omitting) winmds.lock.json under .winapp/. +// [DoNotParallelize] because the tests mutate WINAPP_CLI_CALLER. +[TestClass] +[DoNotParallelize] +public class AddJsBindingsOrchestrationTests : BaseCommandTests +{ + private FakeDynWinrtCodegenService _fakeCodegen = null!; + + protected override IServiceCollection ConfigureServices(IServiceCollection services) + { + // Swap the real codegen for the recording fake. + _fakeCodegen = new FakeDynWinrtCodegenService(); + var existing = services.FirstOrDefault(d => d.ServiceType == typeof(IDynWinrtCodegenService)); + if (existing is not null) + { + services.Remove(existing); + } + services.AddSingleton(_fakeCodegen); + return services; + } + + [TestInitialize] + public void SetNpmCallerEnv() + { + // AddJsBindingsCommand gates behind this exact env value (matches the + // const NpmShimCaller in the command's handler). Tests in this class + // simulate npm-wrapper invocation throughout. + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); + } + + [TestCleanup] + public void ClearNpmCallerEnv() + { + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", null); + } + + private DirectoryInfo SetUpWorkspaceWithLockfile( + string yamlPackagesBlock = "packages:\n - name: Microsoft.WindowsAppSDK\n version: 1.8.39\n", + params (string name, string version, string category, string[] winmdPaths)[] lockfilePackages) + { + var ws = _tempDirectory; + File.WriteAllText(Path.Combine(ws.FullName, "winapp.yaml"), yamlPackagesBlock); + + var winappDir = ws.CreateSubdirectory(".winapp"); + + // Must match the hash AddJsBindingsAsync will compute, or the + // fast-path rejects the lockfile as stale. + var loadedConfig = new ConfigService(new CurrentDirectoryProvider(ws.FullName)) + { + ConfigPath = new FileInfo(Path.Combine(ws.FullName, "winapp.yaml")), + }; + var configForHash = loadedConfig.Load(); + var hash = YamlPackagesHasher.Compute(configForHash.Packages); + + var lockfile = new WinmdsLockfile + { + Schema = WinmdsLockfile.CurrentSchema, + GeneratedAt = DateTimeOffset.UtcNow.ToString("O"), + NugetCacheDir = ws.FullName, + YamlPackagesHash = hash, + Packages = lockfilePackages.Select(p => new WinmdsLockfilePackage + { + Name = p.name, + Version = p.version, + Category = p.category, + Winmds = p.winmdPaths.ToList(), + }).ToList(), + }; + + // Ensure every winmd path exists on disk — fast-path validates this. + foreach (var pkg in lockfilePackages) + { + foreach (var path in pkg.winmdPaths) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + if (!File.Exists(path)) File.WriteAllText(path, "stub winmd"); + } + } + + var json = System.Text.Json.JsonSerializer.Serialize( + lockfile, WinmdsLockfileJsonContext.Default.WinmdsLockfile); + File.WriteAllText(Path.Combine(winappDir.FullName, "winmds.lock.json"), json); + + return ws; + } + + // ------------------------------------------------------------------------- + // true success-path test + // ------------------------------------------------------------------------- + + [TestMethod] + public async Task AddJsBindings_HappyPath_ExitsZero_GeneratesBindings_InjectsRuntimeDep() + { + // Realistic scenario: workspace has a lockfile with one AI package + + // its winmds. fast-path partitions, calls codegen (which we fake to + // succeed), and add jsbindings exits 0. + var aiWinmd = Path.Combine(_tempDirectory.FullName, "fake-cache", + "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); + + SetUpWorkspaceWithLockfile( + lockfilePackages: new[] + { + ("Microsoft.WindowsAppSDK", "1.8.39", "emit", Array.Empty()), + ("Microsoft.WindowsAppSDK.AI", "1.8.39", "emit", new[] { aiWinmd }), + }); + + // Seed a minimal package.json so the runtime-dep injection has a + // file to read/write. + File.WriteAllText( + Path.Combine(_tempDirectory.FullName, "package.json"), + """{"name":"hosting-app","version":"1.0.0","dependencies":{}}"""); + + var addCmd = GetRequiredService(); + var args = new[] { _tempDirectory.FullName, "--ai", "--force" }; + + var exitCode = await ParseAndInvokeWithCaptureAsync(addCmd, args); + + Assert.AreEqual(0, exitCode, + "Happy path must exit 0. Stderr: " + ConsoleStdErr.ToString()); + + // Fake codegen was invoked exactly once. + Assert.AreEqual(1, _fakeCodegen.Calls.Count, + "Codegen should be called exactly once for the bulk pass (no extraTypes)."); + + // Args sanity-check: the AI winmd is in the emit list. + var call = _fakeCodegen.Calls[0]; + Assert.IsTrue(call.EmitWinmds.Any(p => p.EndsWith("Microsoft.Windows.AI.winmd", StringComparison.OrdinalIgnoreCase)), + $"AI winmd must be in emit list. Got: {string.Join(", ", call.EmitWinmds)}"); + + // Output dir created with marker + stub file. + var output = Path.Combine(_tempDirectory.FullName, "bindings", "winrt"); + Assert.IsTrue(Directory.Exists(output), "Output dir must exist."); + Assert.IsTrue(File.Exists(Path.Combine(output, ".dynwinrt-managed")), + "Marker file must be written for next-run wipe gating."); + Assert.IsTrue(File.Exists(Path.Combine(output, "index.js")), + "Stub codegen output must be present."); + + // Yaml was patched with the AI preset. + var yaml = await File.ReadAllTextAsync(Path.Combine(_tempDirectory.FullName, "winapp.yaml")); + StringAssert.Contains(yaml, "Microsoft.WindowsAppSDK.AI", + "yaml's jsBindings.packages should now contain the AI preset."); + } + + // ------------------------------------------------------------------------- + // lockfile fast-path / stale-hash / missing-paths / fallback + // ------------------------------------------------------------------------- + + [TestMethod] + public async Task AddJsBindings_LockfileFastPath_UsedWhenHashMatches() + { + // Setup matches happy path; assert that fast-path was taken by + // verifying we never needed the NuGet cache (which is empty). + var aiWinmd = Path.Combine(_tempDirectory.FullName, "fake-cache", + "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); + SetUpWorkspaceWithLockfile( + lockfilePackages: new[] + { + ("Microsoft.WindowsAppSDK", "1.8.39", "emit", Array.Empty()), + ("Microsoft.WindowsAppSDK.AI", "1.8.39", "emit", new[] { aiWinmd }), + }); + File.WriteAllText( + Path.Combine(_tempDirectory.FullName, "package.json"), + """{"name":"app","version":"1.0.0","dependencies":{}}"""); + + var addCmd = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync(addCmd, new[] { _tempDirectory.FullName, "--ai", "--force" }); + + Assert.AreEqual(0, exitCode); + // Lockfile path produces the AI winmd directly from the lockfile — + // no NuGet cache glob. If we'd taken fallback we'd fail since the + // (real) cache dir doesn't have proper layout. + Assert.AreEqual(1, _fakeCodegen.Calls.Count); + Assert.AreEqual(1, _fakeCodegen.Calls[0].EmitWinmds.Length); + } + + [TestMethod] + public async Task AddJsBindings_StaleYamlHash_FallsBackOrFailsCleanly() + { + // Stale-hash lockfile → fast-path rejects → fallback fails (no NuGet + // cache seeded). Either outcome is fine as long as we don't silently + // use the stale data. + var aiWinmd = Path.Combine(_tempDirectory.FullName, "fake-cache", + "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); + Directory.CreateDirectory(Path.GetDirectoryName(aiWinmd)!); + File.WriteAllText(aiWinmd, "stub"); + + File.WriteAllText(Path.Combine(_tempDirectory.FullName, "winapp.yaml"), + "packages:\n - name: Microsoft.WindowsAppSDK\n version: 1.8.39\n"); + + var winappDir = _tempDirectory.CreateSubdirectory(".winapp"); + + // Hash is "deadbeef" — guaranteed not to match the actual yaml. + var lockfile = new WinmdsLockfile + { + Schema = WinmdsLockfile.CurrentSchema, + GeneratedAt = DateTimeOffset.UtcNow.ToString("O"), + NugetCacheDir = _tempDirectory.FullName, + YamlPackagesHash = "deadbeef-not-a-real-hash", + Packages = new List + { + new() { Name = "Microsoft.WindowsAppSDK.AI", Version = "1.8.39", Category = "emit", + Winmds = { aiWinmd } }, + }, + }; + var json = System.Text.Json.JsonSerializer.Serialize( + lockfile, WinmdsLockfileJsonContext.Default.WinmdsLockfile); + File.WriteAllText(Path.Combine(winappDir.FullName, "winmds.lock.json"), json); + + File.WriteAllText( + Path.Combine(_tempDirectory.FullName, "package.json"), + """{"name":"app","version":"1.0.0","dependencies":{}}"""); + + var addCmd = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync(addCmd, new[] { _tempDirectory.FullName, "--ai", "--force" }); + + // No real NuGet cache → slow-path can't resolve sub-packages. + Assert.AreNotEqual(0, exitCode, + "Stale lockfile + missing NuGet cache must fail cleanly, not silently use stale data."); + // Fake codegen must not be invoked — discovery failed first. + Assert.AreEqual(0, _fakeCodegen.Calls.Count, + "Codegen must not be called when discovery can't find any winmds."); + } + + [TestMethod] + public async Task AddJsBindings_LockfileMissingWinmdPaths_FallsBackOrFailsCleanly() + { + // Lockfile references winmd paths that don't exist on disk + // (simulates `nuget locals all -clear` between restore and add). + var bogusAiWinmd = Path.Combine(_tempDirectory.FullName, "deleted-cache", + "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); + // Intentionally do NOT create the file. + + File.WriteAllText(Path.Combine(_tempDirectory.FullName, "winapp.yaml"), + "packages:\n - name: Microsoft.WindowsAppSDK\n version: 1.8.39\n"); + + var winappDir = _tempDirectory.CreateSubdirectory(".winapp"); + + var loadedConfig = new ConfigService(new CurrentDirectoryProvider(_tempDirectory.FullName)) + { + ConfigPath = new FileInfo(Path.Combine(_tempDirectory.FullName, "winapp.yaml")), + }.Load(); + var hash = YamlPackagesHasher.Compute(loadedConfig.Packages); + + var lockfile = new WinmdsLockfile + { + Schema = WinmdsLockfile.CurrentSchema, + GeneratedAt = DateTimeOffset.UtcNow.ToString("O"), + NugetCacheDir = _tempDirectory.FullName, + YamlPackagesHash = hash, + Packages = new List + { + new() { Name = "Microsoft.WindowsAppSDK.AI", Version = "1.8.39", Category = "emit", + Winmds = { bogusAiWinmd } }, + }, + }; + var json = System.Text.Json.JsonSerializer.Serialize( + lockfile, WinmdsLockfileJsonContext.Default.WinmdsLockfile); + File.WriteAllText(Path.Combine(winappDir.FullName, "winmds.lock.json"), json); + + File.WriteAllText( + Path.Combine(_tempDirectory.FullName, "package.json"), + """{"name":"app","version":"1.0.0","dependencies":{}}"""); + + var addCmd = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync(addCmd, new[] { _tempDirectory.FullName, "--ai", "--force" }); + + // Expect failure (no fallback can find anything either), but the + // key assertion is the codegen wasn't called with bogus paths. + Assert.AreNotEqual(0, exitCode, + "Stale paths + no fallback data must fail cleanly."); + Assert.AreEqual(0, _fakeCodegen.Calls.Count, + "Codegen must NOT be invoked with bogus winmd paths from a stale lockfile."); + } + + [TestMethod] + public async Task AddJsBindings_CodegenThrows_PropagatesAsExit1() + { + // FailWith causes the fake codegen to throw — caller must surface + // exit 1 (not silently succeed). + var aiWinmd = Path.Combine(_tempDirectory.FullName, "fake-cache", + "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); + SetUpWorkspaceWithLockfile( + lockfilePackages: new[] + { + ("Microsoft.WindowsAppSDK.AI", "1.8.39", "emit", new[] { aiWinmd }), + }); + File.WriteAllText( + Path.Combine(_tempDirectory.FullName, "package.json"), + """{"name":"app","version":"1.0.0","dependencies":{}}"""); + + _fakeCodegen.FailWith = new InvalidOperationException("simulated codegen failure"); + + var addCmd = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync(addCmd, new[] { _tempDirectory.FullName, "--ai", "--force" }); + + Assert.AreEqual(1, exitCode, "Codegen failure must surface as non-zero exit."); + Assert.AreEqual(1, _fakeCodegen.Calls.Count, "Codegen was invoked (and threw)."); + } + + [TestMethod] + public async Task AddJsBindings_AllScopedPackagesCategorizedAsSkip_FailsBeforeCodegen() + { + // Scope narrows to a single package that gets categorized as Skip + // → emit set is empty → must fail before spawning codegen. + var winuiWinmd = Path.Combine(_tempDirectory.FullName, "fake-cache", + "microsoft.windowsappsdk.winui", "1.8.39", "metadata", "Microsoft.WindowsAppSDK.WinUI.winmd"); + Directory.CreateDirectory(Path.GetDirectoryName(winuiWinmd)!); + File.WriteAllText(winuiWinmd, "stub"); + + // WinUI is in the default Skip set. + File.WriteAllText(Path.Combine(_tempDirectory.FullName, "winapp.yaml"), + "packages:\n" + + " - name: Microsoft.WindowsAppSDK\n" + + " version: 1.8.39\n" + + "jsBindings:\n" + + " output: bindings/winrt\n" + + " lang: js\n" + + " packages:\n" + + " - Microsoft.WindowsAppSDK.WinUI\n"); + + var winappDir = _tempDirectory.CreateSubdirectory(".winapp"); + var loadedConfig = new ConfigService(new CurrentDirectoryProvider(_tempDirectory.FullName)) + { + ConfigPath = new FileInfo(Path.Combine(_tempDirectory.FullName, "winapp.yaml")), + }.Load(); + var hash = YamlPackagesHasher.Compute(loadedConfig.Packages); + + var lockfile = new WinmdsLockfile + { + Schema = WinmdsLockfile.CurrentSchema, + GeneratedAt = DateTimeOffset.UtcNow.ToString("O"), + NugetCacheDir = _tempDirectory.FullName, + YamlPackagesHash = hash, + Packages = new List + { + new() { Name = "Microsoft.WindowsAppSDK.WinUI", Version = "1.8.39", Category = "skip", + Winmds = { winuiWinmd } }, + }, + }; + var json = System.Text.Json.JsonSerializer.Serialize( + lockfile, WinmdsLockfileJsonContext.Default.WinmdsLockfile); + File.WriteAllText(Path.Combine(winappDir.FullName, "winmds.lock.json"), json); + + File.WriteAllText( + Path.Combine(_tempDirectory.FullName, "package.json"), + """{"name":"app","version":"1.0.0","dependencies":{}}"""); + + var addCmd = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync(addCmd, new[] { _tempDirectory.FullName }); + + Assert.AreNotEqual(0, exitCode, + "All-skipped scope must fail cleanly, not invoke codegen with no emit set."); + Assert.AreEqual(0, _fakeCodegen.Calls.Count, + "Codegen MUST NOT be invoked when there's nothing to emit."); + } + + [TestMethod] + public async Task AddJsBindings_ForceChangesOutput_OldOutputCleanupOnlyAfterCodegenSuccess() + { + // M7 contract: when --force --output changes the output path AND + // codegen succeeds, the previous managed dir is wiped (with marker + // gating); unmanaged dirs are preserved. + var aiWinmd = Path.Combine(_tempDirectory.FullName, "fake-cache", + "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); + SetUpWorkspaceWithLockfile( + lockfilePackages: new[] + { + ("Microsoft.WindowsAppSDK.AI", "1.8.39", "emit", new[] { aiWinmd }), + }); + + // Case A: managed old dir → must be wiped after codegen succeeds. + var managedOld = Path.Combine(_tempDirectory.FullName, "managed-old"); + Directory.CreateDirectory(managedOld); + File.WriteAllText(Path.Combine(managedOld, "Uri.js"), "// generated"); + File.WriteAllText(Path.Combine(managedOld, DynWinrtCodegenService.ManagedMarkerFileName), "# managed"); + + var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); + await File.WriteAllTextAsync(configPath, + "packages:\n" + + " - name: Microsoft.WindowsAppSDK\n" + + " version: 1.8.39\n" + + "jsBindings:\n" + + " output: managed-old\n" + + " lang: js\n" + + " packages:\n" + + " - Microsoft.WindowsAppSDK.AI\n"); + + File.WriteAllText( + Path.Combine(_tempDirectory.FullName, "package.json"), + """{"name":"app","version":"1.0.0","dependencies":{}}"""); + + var addCmd = GetRequiredService(); + var exit = await ParseAndInvokeWithCaptureAsync(addCmd, + new[] { _tempDirectory.FullName, "--force", "--output", "fresh-out" }); + + Assert.AreEqual(0, exit, "Codegen should succeed via fake."); + Assert.IsFalse(File.Exists(Path.Combine(managedOld, "Uri.js")), + "Managed old dir's files must be wiped after a successful output: change."); + + // Case B: unmanaged old dir → preserved even on success. + var unmanagedOld = Path.Combine(_tempDirectory.FullName, "unmanaged-old"); + Directory.CreateDirectory(unmanagedOld); + File.WriteAllText(Path.Combine(unmanagedOld, "user-handcraft.js"), "// hand-written"); + // NO marker. + + await File.WriteAllTextAsync(configPath, + "packages:\n" + + " - name: Microsoft.WindowsAppSDK\n" + + " version: 1.8.39\n" + + "jsBindings:\n" + + " output: unmanaged-old\n" + + " lang: js\n" + + " packages:\n" + + " - Microsoft.WindowsAppSDK.AI\n"); + + var exit2 = await ParseAndInvokeWithCaptureAsync(addCmd, + new[] { _tempDirectory.FullName, "--force", "--output", "fresh-out-2" }); + + Assert.AreEqual(0, exit2); + Assert.IsTrue(File.Exists(Path.Combine(unmanagedOld, "user-handcraft.js")), + "Unmanaged old dir's user files must NOT be wiped — marker-gated safety."); + } + + [TestMethod] + public async Task AddJsBindings_CodegenFails_OldOutputIsPreserved() + { + // M7 contract: codegen failure must leave the old bindings dir + // untouched (don't wipe before we know the new bindings will land). + var aiWinmd = Path.Combine(_tempDirectory.FullName, "fake-cache", + "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); + SetUpWorkspaceWithLockfile( + lockfilePackages: new[] + { + ("Microsoft.WindowsAppSDK.AI", "1.8.39", "emit", new[] { aiWinmd }), + }); + + var managedOld = Path.Combine(_tempDirectory.FullName, "managed-old"); + Directory.CreateDirectory(managedOld); + File.WriteAllText(Path.Combine(managedOld, "Uri.js"), "// generated"); + File.WriteAllText(Path.Combine(managedOld, DynWinrtCodegenService.ManagedMarkerFileName), "# managed"); + + var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); + await File.WriteAllTextAsync(configPath, + "packages:\n" + + " - name: Microsoft.WindowsAppSDK\n" + + " version: 1.8.39\n" + + "jsBindings:\n" + + " output: managed-old\n" + + " lang: js\n" + + " packages:\n" + + " - Microsoft.WindowsAppSDK.AI\n"); + + File.WriteAllText( + Path.Combine(_tempDirectory.FullName, "package.json"), + """{"name":"app","version":"1.0.0","dependencies":{}}"""); + + _fakeCodegen.FailWith = new InvalidOperationException("simulated codegen failure"); + + var addCmd = GetRequiredService(); + var exit = await ParseAndInvokeWithCaptureAsync(addCmd, + new[] { _tempDirectory.FullName, "--force", "--output", "fresh-out" }); + + Assert.AreNotEqual(0, exit, "Codegen failure must surface non-zero."); + Assert.IsTrue(File.Exists(Path.Combine(managedOld, "Uri.js")), + "Codegen failure must NOT wipe the previous bindings."); + } + + [TestMethod] + public async Task AddJsBindings_AdditionalWinmds_FlowsIntoCodegenEmitSet() + { + // jsBindings.additionalWinmds entries must be passed to codegen + // as user-additional emit winmds. + var aiWinmd = Path.Combine(_tempDirectory.FullName, "fake-cache", + "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); + SetUpWorkspaceWithLockfile( + lockfilePackages: new[] + { + ("Microsoft.WindowsAppSDK.AI", "1.8.39", "emit", new[] { aiWinmd }), + }); + + // Seed a real vendor winmd file referenced by additionalWinmds. + var vendorWinmd = Path.Combine(_tempDirectory.FullName, "vendor", "MyCo.Foo.winmd"); + Directory.CreateDirectory(Path.GetDirectoryName(vendorWinmd)!); + File.WriteAllText(vendorWinmd, "stub"); + + var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); + await File.WriteAllTextAsync(configPath, + "packages:\n" + + " - name: Microsoft.WindowsAppSDK\n" + + " version: 1.8.39\n" + + "jsBindings:\n" + + " output: bindings/winrt\n" + + " lang: js\n" + + " packages:\n" + + " - Microsoft.WindowsAppSDK.AI\n" + + " additionalWinmds:\n" + + " - vendor/MyCo.Foo.winmd\n"); + + File.WriteAllText( + Path.Combine(_tempDirectory.FullName, "package.json"), + """{"name":"app","version":"1.0.0","dependencies":{}}"""); + + var addCmd = GetRequiredService(); + var exit = await ParseAndInvokeWithCaptureAsync(addCmd, new[] { _tempDirectory.FullName, "--force" }); + + Assert.AreEqual(0, exit, $"Expected success; stderr: {ConsoleStdErr}"); + Assert.AreEqual(1, _fakeCodegen.Calls.Count); + var call = _fakeCodegen.Calls[0]; + Assert.IsTrue(call.UserAdditionalWinmds.Any(p => p.EndsWith("MyCo.Foo.winmd", StringComparison.OrdinalIgnoreCase)), + $"additionalWinmds must surface to codegen via UserAdditionalWinmds. Got: {string.Join(", ", call.UserAdditionalWinmds)}"); + } + + [TestMethod] + public async Task AddJsBindings_AdditionalRefs_FlowsIntoCodegenRefSet() + { + // jsBindings.additionalRefs entries must flow via UserAdditionalRefs. + var aiWinmd = Path.Combine(_tempDirectory.FullName, "fake-cache", + "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); + SetUpWorkspaceWithLockfile( + lockfilePackages: new[] + { + ("Microsoft.WindowsAppSDK.AI", "1.8.39", "emit", new[] { aiWinmd }), + }); + + var vendorRefWinmd = Path.Combine(_tempDirectory.FullName, "vendor", "BigSDK.winmd"); + Directory.CreateDirectory(Path.GetDirectoryName(vendorRefWinmd)!); + File.WriteAllText(vendorRefWinmd, "stub"); + + var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); + await File.WriteAllTextAsync(configPath, + "packages:\n" + + " - name: Microsoft.WindowsAppSDK\n" + + " version: 1.8.39\n" + + "jsBindings:\n" + + " output: bindings/winrt\n" + + " lang: js\n" + + " packages:\n" + + " - Microsoft.WindowsAppSDK.AI\n" + + " additionalRefs:\n" + + " - vendor/BigSDK.winmd\n"); + + File.WriteAllText( + Path.Combine(_tempDirectory.FullName, "package.json"), + """{"name":"app","version":"1.0.0","dependencies":{}}"""); + + var addCmd = GetRequiredService(); + var exit = await ParseAndInvokeWithCaptureAsync(addCmd, new[] { _tempDirectory.FullName, "--force" }); + + Assert.AreEqual(0, exit, $"Expected success; stderr: {ConsoleStdErr}"); + var call = _fakeCodegen.Calls[0]; + Assert.IsTrue(call.UserAdditionalRefs.Any(p => p.EndsWith("BigSDK.winmd", StringComparison.OrdinalIgnoreCase)), + $"additionalRefs must surface to codegen via UserAdditionalRefs. Got: {string.Join(", ", call.UserAdditionalRefs)}"); + } + + // an attacker-controlled winapp.yaml with additionalWinmds + // or additionalRefs pointing at a UNC path must NOT be probed (which + // would trigger an SMB handshake and leak NTLM credentials). The + // entries are dropped silently-from-codegen but logged. + [TestMethod] + public async Task AddJsBindings_AdditionalWinmds_UncEntry_Rejected_NotProbedNotPassedToCodegen() + { + var aiWinmd = Path.Combine(_tempDirectory.FullName, "fake-cache", + "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); + SetUpWorkspaceWithLockfile( + lockfilePackages: new[] + { + ("Microsoft.WindowsAppSDK.AI", "1.8.39", "emit", new[] { aiWinmd }), + }); + + // Yaml contains BOTH a benign local entry AND a UNC entry. Only + // the benign one should reach codegen. + var legitWinmd = Path.Combine(_tempDirectory.FullName, "vendor", "Legit.winmd"); + Directory.CreateDirectory(Path.GetDirectoryName(legitWinmd)!); + File.WriteAllText(legitWinmd, "stub"); + + // \\nonexistent-attacker.invalid\share\evil.winmd + // Using `.invalid` per RFC 2606 so even an accidental probe can't + // reach a real host. If our guard fails, FileInfo.Exists would + // still SMB-negotiate and the test would timeout / hang. + var uncWinmd = @"\\nonexistent-attacker.invalid\share\evil.winmd"; + + File.WriteAllText(Path.Combine(_tempDirectory.FullName, "winapp.yaml"), + "packages:\n" + + " - name: Microsoft.WindowsAppSDK\n" + + " version: 1.8.39\n" + + "jsBindings:\n" + + " output: bindings/winrt\n" + + " lang: js\n" + + " packages:\n" + + " - Microsoft.WindowsAppSDK.AI\n" + + " additionalWinmds:\n" + + " - vendor/Legit.winmd\n" + + $" - {uncWinmd.Replace("\\", "\\\\")}\n"); + + File.WriteAllText( + Path.Combine(_tempDirectory.FullName, "package.json"), + """{"name":"app","version":"1.0.0","dependencies":{}}"""); + + var addCmd = GetRequiredService(); + + // Bound the test runtime: if our guard fails, FileInfo.Exists on + // the UNC path can take 20+ seconds to time out via SMB + // negotiation. We want < 5s. + var sw = System.Diagnostics.Stopwatch.StartNew(); + var exit = await ParseAndInvokeWithCaptureAsync(addCmd, new[] { _tempDirectory.FullName, "--force" }); + sw.Stop(); + + Assert.AreEqual(0, exit, $"Expected success; stderr: {ConsoleStdErr}"); + Assert.IsTrue(sw.ElapsedMilliseconds < 10_000, + $"UNC entry must be rejected without SMB probe (took {sw.ElapsedMilliseconds}ms; " + + "anything >5s suggests we did probe)."); + + Assert.AreEqual(1, _fakeCodegen.Calls.Count); + var call = _fakeCodegen.Calls[0]; + Assert.IsTrue( + call.UserAdditionalWinmds.Any(p => p.EndsWith("Legit.winmd", StringComparison.OrdinalIgnoreCase)), + "Legit local entry must still reach codegen."); + Assert.IsFalse( + call.UserAdditionalWinmds.Any(p => p.Contains("nonexistent-attacker.invalid", StringComparison.OrdinalIgnoreCase)), + $"UNC entry MUST be dropped — codegen received: {string.Join(", ", call.UserAdditionalWinmds)}"); + } + + // extraTypes-only cherry-pick: only additionalRefs + extraTypes, no + // bulk emit. Must succeed and forward refs + extraTypes to codegen. + [TestMethod] + public async Task AddJsBindings_ExtraTypesOnlyWithAdditionalRefs_Succeeds() + { + // Workspace has WinAppSDK installed; jsBindings declares only + // additionalRefs + extraTypes (no packages, no additionalWinmds). + var vendorWinmd = Path.Combine(_tempDirectory.FullName, "vendor", "Vendor.SDK.winmd"); + Directory.CreateDirectory(Path.GetDirectoryName(vendorWinmd)!); + File.WriteAllText(vendorWinmd, "stub"); + + // Empty lockfile (no emit packages) — reaches the empty-emit guard. + var configForHash = "packages:\n - name: Microsoft.WindowsAppSDK\n version: 1.8.39\n"; + File.WriteAllText(Path.Combine(_tempDirectory.FullName, "winapp.yaml"), configForHash); + var loadedConfig = new ConfigService(new CurrentDirectoryProvider(_tempDirectory.FullName)) + { + ConfigPath = new FileInfo(Path.Combine(_tempDirectory.FullName, "winapp.yaml")), + }.Load(); + var hash = YamlPackagesHasher.Compute(loadedConfig.Packages); + + var winappDir = _tempDirectory.CreateSubdirectory(".winapp"); + var lockfile = new WinmdsLockfile + { + Schema = WinmdsLockfile.CurrentSchema, + GeneratedAt = DateTimeOffset.UtcNow.ToString("O"), + NugetCacheDir = _tempDirectory.FullName, + YamlPackagesHash = hash, + // No emit/refOnly packages — the only way to feed metadata is + // via additionalRefs in the yaml below. + Packages = new List(), + }; + var json = System.Text.Json.JsonSerializer.Serialize( + lockfile, WinmdsLockfileJsonContext.Default.WinmdsLockfile); + File.WriteAllText(Path.Combine(winappDir.FullName, "winmds.lock.json"), json); + + // Rewrite the yaml with jsBindings: additionalRefs + extraTypes only. + File.WriteAllText(Path.Combine(_tempDirectory.FullName, "winapp.yaml"), + "packages:\n" + + " - name: Microsoft.WindowsAppSDK\n" + + " version: 1.8.39\n" + + "jsBindings:\n" + + " output: bindings/winrt\n" + + " lang: js\n" + + " additionalRefs:\n" + + " - vendor/Vendor.SDK.winmd\n" + + " extraTypes:\n" + + " - namespace: Vendor.SDK.Camera\n" + + " classes:\n" + + " - Lens\n" + + " - Sensor\n"); + + File.WriteAllText( + Path.Combine(_tempDirectory.FullName, "package.json"), + """{"name":"app","version":"1.0.0","dependencies":{}}"""); + + var addCmd = GetRequiredService(); + var exit = await ParseAndInvokeWithCaptureAsync(addCmd, new[] { _tempDirectory.FullName, "--force" }); + + Assert.AreEqual(0, exit, + $"extraTypes-only cherry-pick workflow must succeed. stderr: {ConsoleStdErr}"); + Assert.AreEqual(1, _fakeCodegen.Calls.Count, "Codegen must be invoked."); + var call = _fakeCodegen.Calls[0]; + Assert.AreEqual(0, call.EmitWinmds.Length, + "extraTypes-only flow has no bulk emit set — codegen sees zero emit winmds."); + Assert.IsTrue(call.UserAdditionalRefs.Any(p => p.EndsWith("Vendor.SDK.winmd", StringComparison.OrdinalIgnoreCase)), + "Vendor ref winmd must reach codegen as a ref."); + Assert.AreEqual(1, call.Config.ExtraTypes.Count, "extraTypes must be passed through."); + Assert.AreEqual("Vendor.SDK.Camera", call.Config.ExtraTypes[0].Namespace); + CollectionAssert.AreEquivalent( + new[] { "Lens", "Sensor" }, + call.Config.ExtraTypes[0].Classes.ToList()); + } + + // extraTypes-only flow with refs + only-malformed + // extraTypes (blank namespace OR empty classes list) must fail + // BEFORE codegen — otherwise we'd return success with zero bindings + // produced (DynWinrtCodegenService.RunAsync skips malformed entries). + [TestMethod] + public async Task AddJsBindings_ExtraTypesOnlyWithMalformedEntries_FailsBeforeCodegen() + { + var vendorWinmd = Path.Combine(_tempDirectory.FullName, "vendor", "Vendor.SDK.winmd"); + Directory.CreateDirectory(Path.GetDirectoryName(vendorWinmd)!); + File.WriteAllText(vendorWinmd, "stub"); + + var configForHash = "packages:\n - name: Microsoft.WindowsAppSDK\n version: 1.8.39\n"; + File.WriteAllText(Path.Combine(_tempDirectory.FullName, "winapp.yaml"), configForHash); + var loadedConfig = new ConfigService(new CurrentDirectoryProvider(_tempDirectory.FullName)) + { + ConfigPath = new FileInfo(Path.Combine(_tempDirectory.FullName, "winapp.yaml")), + }.Load(); + var hash = YamlPackagesHasher.Compute(loadedConfig.Packages); + + var winappDir = _tempDirectory.CreateSubdirectory(".winapp"); + var lockfile = new WinmdsLockfile + { + Schema = WinmdsLockfile.CurrentSchema, + GeneratedAt = DateTimeOffset.UtcNow.ToString("O"), + NugetCacheDir = _tempDirectory.FullName, + YamlPackagesHash = hash, + Packages = new List(), + }; + var json = System.Text.Json.JsonSerializer.Serialize( + lockfile, WinmdsLockfileJsonContext.Default.WinmdsLockfile); + File.WriteAllText(Path.Combine(winappDir.FullName, "winmds.lock.json"), json); + + // Two malformed entries — one blank namespace, one empty classes + // list. Codegen would silently skip both → zero output. + File.WriteAllText(Path.Combine(_tempDirectory.FullName, "winapp.yaml"), + "packages:\n" + + " - name: Microsoft.WindowsAppSDK\n" + + " version: 1.8.39\n" + + "jsBindings:\n" + + " output: bindings/winrt\n" + + " lang: js\n" + + " additionalRefs:\n" + + " - vendor/Vendor.SDK.winmd\n" + + " extraTypes:\n" + + " - namespace: ''\n" + + " classes:\n" + + " - Lens\n" + + " - namespace: Vendor.SDK.Camera\n" + + " classes: []\n"); + + File.WriteAllText( + Path.Combine(_tempDirectory.FullName, "package.json"), + """{"name":"app","version":"1.0.0","dependencies":{}}"""); + + var addCmd = GetRequiredService(); + var exit = await ParseAndInvokeWithCaptureAsync(addCmd, new[] { _tempDirectory.FullName, "--force" }); + + Assert.AreNotEqual(0, exit, + "Malformed-only extraTypes must fail rather than silently produce zero bindings."); + Assert.AreEqual(0, _fakeCodegen.Calls.Count, + "Codegen MUST NOT be invoked when all extraTypes would be skipped."); + Assert.IsTrue( + ConsoleStdOut.ToString().Contains("malformed", StringComparison.OrdinalIgnoreCase) + || ConsoleStdErr.ToString().Contains("malformed", StringComparison.OrdinalIgnoreCase), + $"Error message must call out the malformed extraTypes. stdout={ConsoleStdOut}; stderr={ConsoleStdErr}"); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/BaseCommandTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/BaseCommandTests.cs index aff40bf2..37ad71ef 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/BaseCommandTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/BaseCommandTests.cs @@ -52,6 +52,7 @@ public void SetupBase() ConfigureServices(services) // Override services .AddSingleton(sp => new CurrentDirectoryProvider(_tempDirectory.FullName)) + .AddSingleton(new FakeNpmWrapperVersionProvider()) .AddSingleton(TestAnsiConsole) .AddLogging(b => { @@ -128,15 +129,9 @@ protected T GetRequiredService() where T : notnull return _serviceProvider.GetRequiredService(); } - /// - /// Ensures a single NuGet package is available in the test NuGet cache by copying it - /// from the real global NuGet cache if available, falling back to downloading from NuGet.org. - /// - /// This avoids expensive HTTP downloads that can timeout (100 s default) when many tests - /// run in parallel (12-way method-level parallelism) and all try to download large packages - /// like Microsoft.WindowsAppSDK.Runtime simultaneously. - /// - /// + // Make a NuGet package available in the test cache — copies from the real + // global cache if present, falls back to NuGet.org. Avoids HTTP timeouts + // when many parallel tests download large packages simultaneously. protected async Task EnsurePackageInTestCacheAsync(string packageId, string version, CancellationToken cancellationToken) { var nugetService = GetRequiredService(); @@ -148,9 +143,7 @@ protected async Task EnsurePackageInTestCacheAsync(string packageId, string vers return; } - // Try to copy from the real NuGet cache (fast, no network needed). - // For EndToEndTests, 'dotnet build' already downloads packages here. - // For PackageCommandTests, previous test runs will have cached them. + // Try the real cache first — `dotnet build` populates it for free. var realCachePath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nuget", "packages", packageId.ToLowerInvariant(), version); @@ -169,9 +162,7 @@ await packageInstallService.EnsurePackageAsync( version: version, cancellationToken: cancellationToken); } - /// - /// Recursively copies a directory and all its contents to a new location. - /// + // Recursively copies a directory and all its contents to a new location. private static void CopyDirectoryRecursive(DirectoryInfo source, DirectoryInfo target) { target.Create(); @@ -184,9 +175,7 @@ private static void CopyDirectoryRecursive(DirectoryInfo source, DirectoryInfo t } } - /// - /// Push default (Enter) answers for manifest prompts (packageName, publisherName, version, description) - /// + // Push default (Enter) answers for manifest prompts (packageName, publisherName, version, description) protected void DefaultAnswers() { TestAnsiConsole.Input.PushKey(ConsoleKey.Enter); @@ -194,4 +183,12 @@ protected void DefaultAnswers() TestAnsiConsole.Input.PushKey(ConsoleKey.Enter); TestAnsiConsole.Input.PushKey(ConsoleKey.Enter); } + + // Stub INpmWrapperVersionProvider for tests so they don't try + // to walk up from the test runner exe path looking for the npm wrapper. + private sealed class FakeNpmWrapperVersionProvider : INpmWrapperVersionProvider + { + public string DynWinrtVersion => "0.0.0-test"; + public string DynWinrtCodegenVersion => "0.0.0-test"; + } } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/ConfigServiceJsBindingsTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/ConfigServiceJsBindingsTests.cs new file mode 100644 index 00000000..1647ff5f --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/ConfigServiceJsBindingsTests.cs @@ -0,0 +1,698 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.Models; +using WinApp.Cli.Services; + +namespace WinApp.Cli.Tests; + +[TestClass] +public class ConfigServiceJsBindingsTests : BaseCommandTests +{ + [TestMethod] + public void Load_NoJsBindings_ReturnsNull() + { + // Arrange + File.WriteAllText(_configService.ConfigPath.FullName, """ + packages: + - name: Microsoft.WindowsAppSDK + version: 1.8.39 + """); + + // Act + var cfg = _configService.Load(); + + // Assert + Assert.AreEqual(1, cfg.Packages.Count); + Assert.IsNull(cfg.JsBindings, "JsBindings must be null when block is absent"); + } + + [TestMethod] + public void Load_MinimalJsBindings_AppliesDefaults() + { + File.WriteAllText(_configService.ConfigPath.FullName, """ + packages: + - name: Microsoft.WindowsAppSDK + version: 1.8.39 + jsBindings: + output: bindings/winrt + """); + + var cfg = _configService.Load(); + + Assert.IsNotNull(cfg.JsBindings); + Assert.AreEqual("js", cfg.JsBindings.Lang, "lang defaults to 'js'"); + Assert.AreEqual("bindings/winrt", cfg.JsBindings.Output); + Assert.AreEqual(0, cfg.JsBindings.ExtraTypes.Count); + // packages: defaults to empty == "all installed packages participate in + // binding generation". Preset application narrows this list. + Assert.AreEqual(0, cfg.JsBindings.Packages.Count, + "Packages list defaults to empty (no preset slicing) — codegen handles Windows.* ref-classification on its own."); + } + + [TestMethod] + public void Load_FullJsBindings_ParsesAllFields() + { + File.WriteAllText(_configService.ConfigPath.FullName, """ + packages: + - name: Microsoft.WindowsAppSDK + version: 1.8.39 + jsBindings: + lang: js + output: src/generated + packages: + - Microsoft.WindowsAppSDK.AI + extraTypes: + - namespace: Windows.Foundation + classes: + - Uri + - PropertyValue + - namespace: Windows.Globalization + classes: + - Calendar + """); + + var cfg = _configService.Load(); + + Assert.IsNotNull(cfg.JsBindings); + Assert.AreEqual("src/generated", cfg.JsBindings.Output); + CollectionAssert.AreEqual( + new[] { "Microsoft.WindowsAppSDK.AI" }, + cfg.JsBindings.Packages); + + Assert.AreEqual(2, cfg.JsBindings.ExtraTypes.Count); + + Assert.AreEqual("Windows.Foundation", cfg.JsBindings.ExtraTypes[0].Namespace); + CollectionAssert.AreEqual(new[] { "Uri", "PropertyValue" }, cfg.JsBindings.ExtraTypes[0].Classes); + + Assert.AreEqual("Windows.Globalization", cfg.JsBindings.ExtraTypes[1].Namespace); + CollectionAssert.AreEqual(new[] { "Calendar" }, cfg.JsBindings.ExtraTypes[1].Classes); + } + + [TestMethod] + public void Load_ExtraTypes_InlineFlowList_Parses() + { + // Inline flow form: classes: [X, Y] — equivalent to a block list. + File.WriteAllText(_configService.ConfigPath.FullName, """ + jsBindings: + output: generated-js + extraTypes: + - namespace: Windows.ApplicationModel + classes: [LimitedAccessFeatures] + - namespace: Windows.Storage + classes: [StorageFile, StorageFolder] + - namespace: Windows.Graphics.Imaging + classes: [BitmapDecoder] + """); + + var cfg = _configService.Load(); + + Assert.IsNotNull(cfg.JsBindings); + Assert.AreEqual(3, cfg.JsBindings.ExtraTypes.Count); + CollectionAssert.AreEqual(new[] { "LimitedAccessFeatures" }, cfg.JsBindings.ExtraTypes[0].Classes); + CollectionAssert.AreEqual(new[] { "StorageFile", "StorageFolder" }, cfg.JsBindings.ExtraTypes[1].Classes); + CollectionAssert.AreEqual(new[] { "BitmapDecoder" }, cfg.JsBindings.ExtraTypes[2].Classes); + } + + [TestMethod] + public void Load_ExtraTypes_ScalarSingleClass_Parses() + { + // Scalar form (the legacy `systemTypes:` style some users wrote): + // classes: SingleClass — treat as a one-item list. + File.WriteAllText(_configService.ConfigPath.FullName, """ + jsBindings: + output: generated-js + extraTypes: + - namespace: Windows.Storage + classes: StorageFile + """); + + var cfg = _configService.Load(); + + Assert.IsNotNull(cfg.JsBindings); + Assert.AreEqual(1, cfg.JsBindings.ExtraTypes.Count); + CollectionAssert.AreEqual(new[] { "StorageFile" }, cfg.JsBindings.ExtraTypes[0].Classes); + } + + [TestMethod] + public void SaveAndLoad_RoundTripsJsBindings() + { + var original = new WinappConfig(); + original.SetVersion("Microsoft.WindowsAppSDK", "1.8.39"); + original.JsBindings = new JsBindingsConfig + { + Lang = "js", + Output = "bindings/winrt", + Packages = new() { "Microsoft.WindowsAppSDK.AI" }, + ExtraTypes = new() + { + new JsBindingsExtraType + { + Namespace = "Windows.Foundation", + Classes = new() { "Uri" }, + }, + }, + }; + + _configService.Save(original); + var roundTrip = _configService.Load(); + + Assert.IsNotNull(roundTrip.JsBindings); + Assert.AreEqual("js", roundTrip.JsBindings.Lang); + Assert.AreEqual("bindings/winrt", roundTrip.JsBindings.Output); + CollectionAssert.AreEqual( + new[] { "Microsoft.WindowsAppSDK.AI" }, + roundTrip.JsBindings.Packages, + "Round-trip must preserve the packages slice exactly."); + Assert.AreEqual(1, roundTrip.JsBindings.ExtraTypes.Count); + Assert.AreEqual("Windows.Foundation", roundTrip.JsBindings.ExtraTypes[0].Namespace); + CollectionAssert.AreEqual(new[] { "Uri" }, roundTrip.JsBindings.ExtraTypes[0].Classes); + } + + [TestMethod] + public void Load_PackagesAfterJsBindings_StillParsesPackages() + { + // The yaml block order should not matter for top-level sections. + File.WriteAllText(_configService.ConfigPath.FullName, """ + jsBindings: + output: bindings/winrt + packages: + - name: Microsoft.WindowsAppSDK + version: 1.8.39 + """); + + var cfg = _configService.Load(); + + Assert.IsNotNull(cfg.JsBindings); + Assert.AreEqual(1, cfg.Packages.Count); + Assert.AreEqual("Microsoft.WindowsAppSDK", cfg.Packages[0].Name); + Assert.AreEqual("1.8.39", cfg.Packages[0].Version); + } + + [TestMethod] + public void Load_AdditionalWinmds_ParsesRelativeAndAbsolutePaths() + { + File.WriteAllText(_configService.ConfigPath.FullName, """ + packages: + - name: Microsoft.WindowsAppSDK + version: 1.8.39 + jsBindings: + output: bindings/winrt + additionalWinmds: + - vendor/MyCompany.Foo.winmd + - C:\absolute\path\Other.winmd + - sibling.winmd + """); + + var cfg = _configService.Load(); + + Assert.IsNotNull(cfg.JsBindings); + CollectionAssert.AreEqual( + new[] + { + "vendor/MyCompany.Foo.winmd", + @"C:\absolute\path\Other.winmd", + "sibling.winmd", + }, + cfg.JsBindings.AdditionalWinmds, + "AdditionalWinmds entries must round-trip in declaration order, accepting both relative and absolute paths"); + } + + [TestMethod] + public void Load_AdditionalWinmds_DedupesCaseInsensitive() + { + File.WriteAllText(_configService.ConfigPath.FullName, """ + packages: + - name: Microsoft.WindowsAppSDK + version: 1.8.39 + jsBindings: + output: bindings/winrt + additionalWinmds: + - vendor/Foo.winmd + - Vendor/foo.WINMD + - vendor/Bar.winmd + """); + + var cfg = _configService.Load(); + + Assert.IsNotNull(cfg.JsBindings); + Assert.AreEqual( + 2, + cfg.JsBindings.AdditionalWinmds.Count, + "Duplicate paths (case-insensitive) must be deduped to keep winmd list file deterministic"); + } + + [TestMethod] + public void SaveAndLoad_AdditionalWinmds_RoundTrips() + { + var original = new WinappConfig(); + original.SetVersion("Microsoft.WindowsAppSDK", "1.8.39"); + original.JsBindings = new JsBindingsConfig + { + Lang = "js", + Output = "bindings/winrt", + AdditionalWinmds = new() { "vendor/Foo.winmd", @"C:\abs\Bar.winmd" }, + }; + + _configService.Save(original); + var roundTrip = _configService.Load(); + + Assert.IsNotNull(roundTrip.JsBindings); + CollectionAssert.AreEqual( + new[] { "vendor/Foo.winmd", @"C:\abs\Bar.winmd" }, + roundTrip.JsBindings.AdditionalWinmds, + "additionalWinmds must round-trip declaration order intact"); + } + + // ------------------------------------------------------------------------- + // additionalRefs — same parsing rules as additionalWinmds, but flows into + // the codegen's --ref channel, not the winmd list file. Pairs with + // extraTypes for cherry-picking from a vendor winmd. + // ------------------------------------------------------------------------- + + [TestMethod] + public void Load_AdditionalRefs_ParsesRelativeAndAbsolutePaths() + { + File.WriteAllText(_configService.ConfigPath.FullName, """ + packages: + - name: Microsoft.WindowsAppSDK + version: 1.8.39 + jsBindings: + output: bindings/winrt + additionalRefs: + - vendor/BigVendor.winmd + - C:\shared\OtherCompany.SDK.winmd + extraTypes: + - namespace: BigVendor.Camera + classes: + - Lens + - Sensor + """); + + var cfg = _configService.Load(); + + Assert.IsNotNull(cfg.JsBindings); + CollectionAssert.AreEqual( + new[] { "vendor/BigVendor.winmd", @"C:\shared\OtherCompany.SDK.winmd" }, + cfg.JsBindings.AdditionalRefs); + // Adjacent extraTypes block must still parse correctly when + // additionalRefs precedes it (regression guard for parser state). + Assert.AreEqual(1, cfg.JsBindings.ExtraTypes.Count); + Assert.AreEqual("BigVendor.Camera", cfg.JsBindings.ExtraTypes[0].Namespace); + CollectionAssert.AreEqual(new[] { "Lens", "Sensor" }, cfg.JsBindings.ExtraTypes[0].Classes); + } + + [TestMethod] + public void Load_AdditionalRefs_DedupesCaseInsensitive() + { + File.WriteAllText(_configService.ConfigPath.FullName, """ + packages: + - name: Microsoft.WindowsAppSDK + version: 1.8.39 + jsBindings: + output: bindings/winrt + additionalRefs: + - vendor/Foo.winmd + - VENDOR/FOO.WINMD + - vendor/Bar.winmd + """); + + var cfg = _configService.Load(); + + Assert.IsNotNull(cfg.JsBindings); + Assert.AreEqual(2, cfg.JsBindings.AdditionalRefs.Count, + "additionalRefs must dedupe case-insensitive (parser-level guard)"); + } + + [TestMethod] + public void SaveAndLoad_AdditionalRefs_RoundTrips() + { + var original = new WinappConfig(); + original.SetVersion("Microsoft.WindowsAppSDK", "1.8.39"); + original.JsBindings = new JsBindingsConfig + { + Lang = "js", + Output = "bindings/winrt", + AdditionalWinmds = new() { "vendor/Foo.winmd" }, + AdditionalRefs = new() { "vendor/BigVendor.winmd", @"C:\abs\OtherSdk.winmd" }, + ExtraTypes = + { + new JsBindingsExtraType + { + Namespace = "BigVendor.Camera", + Classes = { "Lens" }, + }, + }, + }; + + _configService.Save(original); + var roundTrip = _configService.Load(); + + Assert.IsNotNull(roundTrip.JsBindings); + // Both list fields must coexist and round-trip in their declaration order. + CollectionAssert.AreEqual( + new[] { "vendor/Foo.winmd" }, + roundTrip.JsBindings.AdditionalWinmds); + CollectionAssert.AreEqual( + new[] { "vendor/BigVendor.winmd", @"C:\abs\OtherSdk.winmd" }, + roundTrip.JsBindings.AdditionalRefs); + // Extras-types adjacent block must also survive + Assert.AreEqual(1, roundTrip.JsBindings.ExtraTypes.Count); + Assert.AreEqual("BigVendor.Camera", roundTrip.JsBindings.ExtraTypes[0].Namespace); + } + + [TestMethod] + public void Load_NoAdditionalRefs_DefaultsToEmpty() + { + File.WriteAllText(_configService.ConfigPath.FullName, """ + packages: + - name: Microsoft.WindowsAppSDK + version: 1.8.39 + jsBindings: + output: bindings/winrt + """); + + var cfg = _configService.Load(); + + Assert.IsNotNull(cfg.JsBindings); + Assert.IsNotNull(cfg.JsBindings.AdditionalRefs); + Assert.AreEqual(0, cfg.JsBindings.AdditionalRefs.Count); + } + + // ------------------------------------------------------------------------- + // packages — preset slice (NuGet package IDs that the codegen run scopes + // to). Empty list = all installed packages participate. + // ------------------------------------------------------------------------- + + [TestMethod] + public void Load_Packages_ParsesAndDedupes() + { + File.WriteAllText(_configService.ConfigPath.FullName, """ + packages: + - name: Microsoft.WindowsAppSDK + version: 1.8.39 + jsBindings: + output: bindings/winrt + packages: + - Microsoft.WindowsAppSDK.AI + - Microsoft.WindowsAppSDK + - microsoft.windowsappsdk.ai + """); + + var cfg = _configService.Load(); + + Assert.IsNotNull(cfg.JsBindings); + CollectionAssert.AreEqual( + new[] { "Microsoft.WindowsAppSDK.AI", "Microsoft.WindowsAppSDK" }, + cfg.JsBindings.Packages, + "Packages list must dedupe case-insensitively while preserving the first-seen casing."); + } + + [TestMethod] + public void SaveAndLoad_Packages_RoundTrips() + { + var original = new WinappConfig(); + original.SetVersion("Microsoft.WindowsAppSDK", "1.8.39"); + original.JsBindings = new JsBindingsConfig + { + Lang = "js", + Output = "bindings/winrt", + Packages = new() { "Microsoft.WindowsAppSDK.AI" }, + }; + + _configService.Save(original); + var roundTrip = _configService.Load(); + + Assert.IsNotNull(roundTrip.JsBindings); + CollectionAssert.AreEqual( + new[] { "Microsoft.WindowsAppSDK.AI" }, + roundTrip.JsBindings.Packages); + } + + [TestMethod] + public void Load_NoPackages_DefaultsToEmpty() + { + // Empty list (NOT null) — semantics is "all installed packages participate". + File.WriteAllText(_configService.ConfigPath.FullName, """ + packages: + - name: Microsoft.WindowsAppSDK + version: 1.8.39 + jsBindings: + output: bindings/winrt + """); + + var cfg = _configService.Load(); + + Assert.IsNotNull(cfg.JsBindings); + Assert.IsNotNull(cfg.JsBindings.Packages); + Assert.AreEqual(0, cfg.JsBindings.Packages.Count); + } + + // ------------------------------------------------------------------------- + // Save() preserves comments + unknown fields outside jsBindings: + // ------------------------------------------------------------------------- + + [TestMethod] + public void Save_PreservesCommentsAndUnknownFields_OutsideJsBindings() + { + // Round-trip test: the user has a yaml with comments, a custom + // top-level field winapp doesn't know about, and a jsBindings: block. + // Save() must preserve everything except the jsBindings: block itself. + var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); + var originalYaml = + "# Top of file comment — must survive\n" + + "\n" + + "packages:\n" + + " # inline comment near a package\n" + + " - name: Microsoft.WindowsAppSDK\n" + + " version: 1.8.39\n" + + " - name: Microsoft.Windows.CppWinRT\n" + + " version: 2.0.250303.1\n" + + "\n" + + "# A user-added top-level field winapp's model doesn't know about\n" + + "customField:\n" + + " enabled: true\n" + + " notes: should-survive\n" + + "\n" + + "jsBindings:\n" + + " output: old/output\n" + + " lang: js\n"; + File.WriteAllText(configPath, originalYaml); + + var loaded = _configService.Load(); + // Mutate just jsBindings.output (simulating what add jsbindings does). + loaded.JsBindings!.Output = "new/output"; + _configService.SaveJsBindingsOnly(loaded); + + var roundtripped = File.ReadAllText(configPath); + + // Comments preserved. + StringAssert.Contains(roundtripped, "# Top of file comment — must survive", + "Top-of-file comments must survive SaveJsBindingsOnly"); + StringAssert.Contains(roundtripped, "# inline comment near a package", + "Inline comments must survive SaveJsBindingsOnly"); + StringAssert.Contains(roundtripped, "# A user-added top-level field winapp's model doesn't know about", + "Comments above unknown fields must survive SaveJsBindingsOnly"); + + // Unknown top-level field preserved verbatim. + StringAssert.Contains(roundtripped, "customField:", + "Unknown top-level fields must survive SaveJsBindingsOnly"); + StringAssert.Contains(roundtripped, "enabled: true", + "Unknown fields' children must survive SaveJsBindingsOnly"); + StringAssert.Contains(roundtripped, "notes: should-survive", + "Unknown fields' children must survive SaveJsBindingsOnly"); + + // Original packages: untouched. + StringAssert.Contains(roundtripped, "Microsoft.WindowsAppSDK", "packages: must survive SaveJsBindingsOnly"); + StringAssert.Contains(roundtripped, "Microsoft.Windows.CppWinRT"); + StringAssert.Contains(roundtripped, "version: 1.8.39"); + + // jsBindings: was patched. + StringAssert.Contains(roundtripped, "new/output", + "jsBindings.output should reflect the in-memory mutation"); + Assert.IsFalse(roundtripped.Contains("old/output"), + "Old jsBindings.output should be gone"); + } + + [TestMethod] + public void SaveJsBindingsOnly_AppendsJsBindings_WhenAbsent() + { + // Yaml has packages: but no jsBindings: block. After in-memory + // injection + SaveJsBindingsOnly(), the new block should be appended. + var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); + var originalYaml = + "# pinning my packages\n" + + "packages:\n" + + " - name: Microsoft.WindowsAppSDK\n" + + " version: 1.8.39\n"; + File.WriteAllText(configPath, originalYaml); + + var loaded = _configService.Load(); + loaded.JsBindings = new WinApp.Cli.Models.JsBindingsConfig + { + Output = "src/bindings", + Lang = "js", + Packages = { "Microsoft.WindowsAppSDK.AI" }, + }; + _configService.SaveJsBindingsOnly(loaded); + + var roundtripped = File.ReadAllText(configPath); + StringAssert.Contains(roundtripped, "# pinning my packages", + "Existing comments must survive append"); + StringAssert.Contains(roundtripped, "jsBindings:", + "New jsBindings block must be appended"); + StringAssert.Contains(roundtripped, "src/bindings"); + StringAssert.Contains(roundtripped, "Microsoft.WindowsAppSDK.AI"); + } + + [TestMethod] + public void Save_PersistsPackageVersionChanges_OverwritingExistingFile() + { + // Regression guard for review #3 H1: ConfigService.Save() must persist + // ALL model state, not just jsBindings:. winapp update mutates pinned + // versions via SetVersion(...) then calls Save() — if Save() only + // patched jsBindings:, those version bumps would silently disappear. + var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); + File.WriteAllText(configPath, + "packages:\n" + + " - name: Microsoft.WindowsAppSDK\n" + + " version: 1.8.39\n" + + " - name: Microsoft.Windows.SDK.BuildTools\n" + + " version: 10.0.26100.6901\n"); + + var cfg = _configService.Load(); + // Simulate winapp update bumping versions. + cfg.SetVersion("Microsoft.WindowsAppSDK", "1.9.0-newer"); + cfg.SetVersion("Microsoft.Windows.SDK.BuildTools", "10.0.99999.9"); + _configService.Save(cfg); + + var roundtripped = File.ReadAllText(configPath); + StringAssert.Contains(roundtripped, "1.9.0-newer", + "winapp update's version bump must persist; previously the splice-only Save() silently dropped it."); + StringAssert.Contains(roundtripped, "10.0.99999.9"); + Assert.IsFalse(roundtripped.Contains("1.8.39"), + "Old version string must be gone after Save()"); + } + + [TestMethod] + public void Load_UnknownTopLevelFieldAfterJsBindings_IsNotAbsorbed() + { + // Regression for review #3 M5: an unknown zero-indent key after + // jsBindings: must not have its children parsed as JS-binding content. + var yaml = + "packages:\n" + + " - name: Microsoft.WindowsAppSDK\n" + + " version: 1.8.39\n" + + "jsBindings:\n" + + " output: bindings/winrt\n" + + " packages:\n" + + " - Microsoft.WindowsAppSDK.AI\n" + + "customField:\n" + + " output: should-not-clobber-jsbindings-output\n" + + " packages:\n" + + " - Should.Not.Appear.In.JsBindings.Packages\n"; + File.WriteAllText(_configService.ConfigPath.FullName, yaml); + + var loaded = _configService.Load(); + + Assert.IsNotNull(loaded.JsBindings); + Assert.AreEqual("bindings/winrt", loaded.JsBindings!.Output, + "Unknown top-level key must NOT overwrite jsBindings.output"); + CollectionAssert.AreEqual( + new[] { "Microsoft.WindowsAppSDK.AI" }, + loaded.JsBindings.Packages.ToList(), + "Unknown top-level key's list children must NOT leak into jsBindings.packages"); + } + + [TestMethod] + public void SaveJsBindingsOnly_PreservesTopLevelCommentAfterJsBindingsBlock() + { + // Regression: a zero-indent `# comment` between jsBindings: and the + // next top-level key must survive the splice (it belongs to the next + // section, not jsBindings). + var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); + File.WriteAllText(configPath, + "packages:\n" + + " - name: Microsoft.WindowsAppSDK\n" + + " version: 1.8.39\n" + + "jsBindings:\n" + + " output: old/output\n" + + " lang: js\n" + + "\n" + + "# IMPORTANT comment about customField (must survive splice)\n" + + "customField:\n" + + " value: 42\n"); + + var loaded = _configService.Load(); + loaded.JsBindings!.Output = "new/output"; + _configService.SaveJsBindingsOnly(loaded); + + var roundtripped = File.ReadAllText(configPath); + StringAssert.Contains(roundtripped, "# IMPORTANT comment about customField", + "Zero-indent comment between jsBindings: and the next top-level key must survive splice."); + StringAssert.Contains(roundtripped, "customField:"); + StringAssert.Contains(roundtripped, "new/output"); + Assert.IsFalse(roundtripped.Contains("old/output")); + } + + // silent lossy-fallback in SaveJsBindingsOnly removed. + // When the read or splice fails on an EXISTING file, the call must + // throw rather than overwrite with a full serialization that strips + // comments and unknown fields. + [TestMethod] + public void SaveJsBindingsOnly_ExistingFileLocked_ThrowsRatherThanLossyOverwrite() + { + var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); + var originalYaml = + "# top-level user comment that must survive\n" + + "packages:\n" + + " - name: Microsoft.WindowsAppSDK\n" + + " version: 1.8.39\n" + + "customField: 42 # unknown YAML field\n"; + File.WriteAllText(configPath, originalYaml); + + var cfg = _configService.Load(); + cfg.JsBindings = new JsBindingsConfig { Output = "bindings/winrt", Lang = "js" }; + + // Hold an exclusive write lock that blocks File.ReadAllText. + using var blocker = new FileStream(configPath, FileMode.Open, FileAccess.Write, FileShare.None); + + var ex = Assert.ThrowsExactly( + () => _configService.SaveJsBindingsOnly(cfg)); + StringAssert.Contains(ex.Message, "preserving comments", + "Error must explain the lossy-write guard rather than silently corrupting the file."); + StringAssert.Contains(ex.Message, "winapp.yaml", + "Error must include the affected file path."); + + // Release the lock and verify the file on disk is the original + // (not a lossy full-serialization). + blocker.Dispose(); + var afterFailure = File.ReadAllText(configPath); + Assert.AreEqual(originalYaml, afterFailure, + "On failure, the file on disk must remain bit-identical to the original — " + + "no comments stripped, no unknown fields dropped."); + } + + [TestMethod] + public void SaveJsBindingsOnly_NewFile_WritesViaStringify() + { + // Regression guard: the new-file (ConfigPath.Exists == false) path + // still uses full serialization. There is nothing to preserve, so + // Stringify is the right behavior. + var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); + Assert.IsFalse(File.Exists(configPath), "Pre-condition: no existing config."); + + var cfg = new WinappConfig + { + Packages = { new PackagePin { Name = "Microsoft.WindowsAppSDK", Version = "1.8.39" } }, + JsBindings = new JsBindingsConfig { Output = "bindings/winrt", Lang = "js" }, + }; + + _configService.SaveJsBindingsOnly(cfg); + + Assert.IsTrue(File.Exists(configPath), "File must be created."); + var content = File.ReadAllText(configPath); + StringAssert.Contains(content, "jsBindings:"); + StringAssert.Contains(content, "output: bindings/winrt"); + } +} \ No newline at end of file diff --git a/src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenArgvTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenArgvTests.cs new file mode 100644 index 00000000..99d123c9 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenArgvTests.cs @@ -0,0 +1,348 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.Models; +using WinApp.Cli.Services; + +namespace WinApp.Cli.Tests; + +// split of the historical DynWinrtCodegenServiceTests. +// Scope: argv construction (BuildBulkArgs / BuildExtraTypeArgs) + +// upstream input-collection helpers that feed the argv builders. +[TestClass] +public class DynWinrtCodegenArgvTests +{ + public TestContext TestContext { get; set; } = null!; + + private DirectoryInfo _temp = null!; + + [TestInitialize] + public void Init() + { + _temp = new DirectoryInfo(Path.Combine(Path.GetTempPath(), $"DynWinrtCodegenArgvTests_{Guid.NewGuid():N}")); + _temp.Create(); + } + + [TestCleanup] + public void Cleanup() + { + try { _temp.Delete(recursive: true); } catch { /* ignore */ } + } + + // ------------------------------------------------------------------------- + // CollectListedWinmds — dedup + Windows SDK appended at the end. + // ------------------------------------------------------------------------- + + [TestMethod] + public void CollectListedWinmds_DedupesAcrossSources() + { + var a = new FileInfo(Path.Combine(_temp.FullName, "A.winmd")); + var b = new FileInfo(Path.Combine(_temp.FullName, "B.winmd")); + var sdk = new FileInfo(Path.Combine(_temp.FullName, "Windows.winmd")); + var winmds = new[] { a, b }; + var userAdditional = new[] { b, a }; + + var result = DynWinrtCodegenService.CollectListedWinmds(winmds, userAdditional, sdk); + + Assert.AreEqual(3, result.Count, "Three unique winmds expected after dedup."); + Assert.AreEqual(a.FullName, result[0].FullName); + Assert.AreEqual(b.FullName, result[1].FullName); + Assert.AreEqual(sdk.FullName, result[2].FullName, "Windows SDK winmd must come last."); + } + + [TestMethod] + public void CollectListedWinmds_NullWindowsSdkWinmd_OmittedSilently() + { + var a = new FileInfo(Path.Combine(_temp.FullName, "A.winmd")); + var result = DynWinrtCodegenService.CollectListedWinmds(new[] { a }, userAdditional: null, windowsSdkWinmd: null); + Assert.AreEqual(1, result.Count); + } + + // ------------------------------------------------------------------------- + // CollectRefWinmds — additionalWinmds wins over additionalRefs. + // ------------------------------------------------------------------------- + + [TestMethod] + public void CollectRefWinmds_FileAlsoInRsp_DroppedFromRefs() + { + var shared = new FileInfo(Path.Combine(_temp.FullName, "Shared.winmd")); + var refOnly = new FileInfo(Path.Combine(_temp.FullName, "RefOnly.winmd")); + var list = new[] { shared }; + var refs = new[] { shared, refOnly }; + + var result = DynWinrtCodegenService.CollectRefWinmds(refs, list); + + Assert.AreEqual(1, result.Count); + Assert.AreEqual(refOnly.FullName, result[0].FullName, + "When a path appears in both additionalWinmds and additionalRefs, additionalWinmds wins so refs only keeps the unique entry."); + } + + [TestMethod] + public void CollectRefWinmds_NullOrEmpty_ReturnsEmpty() + { + var list = Array.Empty(); + Assert.AreEqual(0, DynWinrtCodegenService.CollectRefWinmds(null, list).Count); + Assert.AreEqual(0, DynWinrtCodegenService.CollectRefWinmds(Array.Empty(), list).Count); + } + + // ------------------------------------------------------------------------- + // ScopeUsedVersionsToBindingPackages — preset slicing primitive. + // ------------------------------------------------------------------------- + + [TestMethod] + public void ScopeUsedVersionsToBindingPackages_NullOrEmptyPackages_ReturnsAll() + { + var input = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Microsoft.WindowsAppSDK"] = "1.8.39", + ["Microsoft.WindowsAppSDK.AI"] = "1.8.39", + }; + + var resultNull = JsBindingsWorkspaceService.ScopeUsedVersionsToBindingPackages(input, null); + Assert.AreEqual(2, resultNull.Count, "Null packages list = all packages participate (pre-preset default)."); + + var resultEmpty = JsBindingsWorkspaceService.ScopeUsedVersionsToBindingPackages(input, Array.Empty()); + Assert.AreEqual(2, resultEmpty.Count, "Empty packages list = all packages participate."); + } + + [TestMethod] + public void ScopeUsedVersionsToBindingPackages_FiltersDictionaryToAllowSet_CaseInsensitive() + { + var input = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Microsoft.WindowsAppSDK"] = "1.8.39", + ["Microsoft.WindowsAppSDK.AI"] = "1.8.39", + ["Microsoft.Windows.SDK.NET.Ref"] = "10.0.26100.93", + }; + var preset = new[] { "microsoft.windowsappsdk.ai" }; + + var result = JsBindingsWorkspaceService.ScopeUsedVersionsToBindingPackages(input, preset); + + Assert.AreEqual(1, result.Count, "Only the preset-listed package should survive the scope."); + Assert.IsTrue(result.ContainsKey("Microsoft.WindowsAppSDK.AI")); + Assert.AreEqual("1.8.39", result["Microsoft.WindowsAppSDK.AI"]); + } + + // ------------------------------------------------------------------------- + // MergeRefWinmds — combines (package-derived ref-only winmds) with + // (user-supplied jsBindings.additionalRefs). Pure list-merge + dedup. + // ------------------------------------------------------------------------- + + [TestMethod] + public void MergeRefWinmds_BothEmpty_ReturnsEmpty() + { + var result = JsBindingsWorkspaceService.MergeRefWinmds(Array.Empty(), null); + Assert.AreEqual(0, result.Count); + var result2 = JsBindingsWorkspaceService.MergeRefWinmds(Array.Empty(), Array.Empty()); + Assert.AreEqual(0, result2.Count); + } + + [TestMethod] + public void MergeRefWinmds_PreservesInputOrder_FirstThenSecond() + { + var first = new[] + { + new FileInfo(Path.Combine(_temp.FullName, "pkg-A.winmd")), + new FileInfo(Path.Combine(_temp.FullName, "pkg-B.winmd")), + }; + var second = new[] + { + new FileInfo(Path.Combine(_temp.FullName, "user-X.winmd")), + new FileInfo(Path.Combine(_temp.FullName, "user-Y.winmd")), + }; + + var result = JsBindingsWorkspaceService.MergeRefWinmds(first, second); + + Assert.AreEqual(4, result.Count); + Assert.IsTrue(result[0].FullName.EndsWith("pkg-A.winmd", StringComparison.OrdinalIgnoreCase)); + Assert.IsTrue(result[1].FullName.EndsWith("pkg-B.winmd", StringComparison.OrdinalIgnoreCase)); + Assert.IsTrue(result[2].FullName.EndsWith("user-X.winmd", StringComparison.OrdinalIgnoreCase)); + Assert.IsTrue(result[3].FullName.EndsWith("user-Y.winmd", StringComparison.OrdinalIgnoreCase)); + } + + [TestMethod] + public void MergeRefWinmds_DedupesByFullName_CaseInsensitive() + { + var path = Path.Combine(_temp.FullName, "Foo.winmd"); + var first = new[] { new FileInfo(path) }; + var second = new[] + { + new FileInfo(path.ToUpperInvariant()), + new FileInfo(Path.Combine(_temp.FullName, "Bar.winmd")), + }; + + var result = JsBindingsWorkspaceService.MergeRefWinmds(first, second); + + Assert.AreEqual(2, result.Count, "Foo.winmd must appear once even though second list has a case-variant duplicate."); + } + + // ------------------------------------------------------------------------- + // IsNetworkPath — classify UNC / network paths so the + // additionalWinmds / additionalRefs / lockfile path probes can refuse + // to negotiate SMB with attacker-controlled hosts. + // ------------------------------------------------------------------------- + + [TestMethod] + [DataRow("\\\\server\\share\\file.winmd", true, "Plain UNC")] + [DataRow("//server/share/file.winmd", true, "Forward-slash UNC")] + [DataRow("\\\\attacker.example.com\\share\\evil.winmd", true, "Hostname UNC")] + [DataRow("\\\\?\\UNC\\server\\share\\file.winmd", true, "Long-path UNC")] + [DataRow("\\\\.\\UNC\\server\\share\\file.winmd", true, "Device-path UNC")] + [DataRow("\\\\?\\unc\\server\\share\\file.winmd", true, "Long-path UNC lowercase")] + [DataRow("C:\\Users\\me\\winmds\\Foo.winmd", false, "Local DOS path")] + [DataRow("C:/Users/me/winmds/Foo.winmd", false, "Local forward-slash DOS path")] + [DataRow("\\\\?\\C:\\Users\\me\\Foo.winmd", false, "Local long-path DOS")] + [DataRow("\\\\.\\C:\\Users\\me\\Foo.winmd", false, "Local device DOS")] + [DataRow("relative/file.winmd", false, "Relative path")] + [DataRow("", false, "Empty")] + public void IsNetworkPath_ClassifiesPathsCorrectly(string path, bool expected, string label) + { + Assert.AreEqual(expected, JsBindingsWorkspaceService.IsNetworkPath(path), + $"Path classification mismatch for {label}: {path}"); + } + + // ------------------------------------------------------------------------- + // BuildBulkArgs / BuildExtraTypeArgs — full argv construction. + // ------------------------------------------------------------------------- + + [TestMethod] + public void BuildBulkArgs_IncludesGenerateAndWinmdAndOutputAndLang() + { + var winmds = new[] + { + new FileInfo(Path.Combine(_temp.FullName, "A.winmd")), + new FileInfo(Path.Combine(_temp.FullName, "B.winmd")), + }; + var refs = new List(); + var config = new JsBindingsConfig { Lang = "js", Output = "bindings/winrt" }; + var args = DynWinrtCodegenService.BuildBulkArgs(Array.Empty(), winmds, _temp, config, refs); + + Assert.AreEqual("generate", args[0]); + CollectionAssert.Contains(args, "--winmd"); + Assert.IsTrue(args.Any(a => a.Contains("A.winmd") && a.Contains("B.winmd") && a.Contains(';')), + "Multiple winmds must be semicolon-joined under --winmd."); + CollectionAssert.Contains(args, "--output"); + CollectionAssert.Contains(args, "--lang"); + CollectionAssert.Contains(args, "js"); + Assert.IsFalse(args.Contains("--ref"), "No --ref when ref list is empty."); + Assert.IsFalse(args.Contains("--pyi"), "No --pyi unless lang=py."); + } + + [TestMethod] + public void BuildBulkArgs_WithRefs_IncludesRefFlag() + { + var winmds = new[] { new FileInfo(Path.Combine(_temp.FullName, "A.winmd")) }; + var refs = new List + { + new(Path.Combine(_temp.FullName, "R1.winmd")), + new(Path.Combine(_temp.FullName, "R2.winmd")), + }; + var config = new JsBindingsConfig { Lang = "js" }; + var args = DynWinrtCodegenService.BuildBulkArgs(Array.Empty(), winmds, _temp, config, refs); + + var refIdx = args.IndexOf("--ref"); + Assert.IsTrue(refIdx >= 0, "Expected --ref flag."); + Assert.IsTrue(args[refIdx + 1].Contains("R1.winmd") && args[refIdx + 1].Contains("R2.winmd") + && args[refIdx + 1].Contains(';'), + $"Ref winmds must be semicolon-joined. Got: {args[refIdx + 1]}"); + } + + [TestMethod] + public void BuildBulkArgs_PyLang_AddsPyiFlag() + { + var winmds = new[] { new FileInfo(Path.Combine(_temp.FullName, "A.winmd")) }; + var config = new JsBindingsConfig { Lang = "py" }; + var args = DynWinrtCodegenService.BuildBulkArgs(Array.Empty(), winmds, _temp, config, new List()); + + CollectionAssert.Contains(args, "--pyi", "Python lang must emit --pyi."); + CollectionAssert.Contains(args, "py"); + } + + [TestMethod] + public void BuildBulkArgs_PrefixArgsPreserved() + { + var winmds = new[] { new FileInfo(Path.Combine(_temp.FullName, "A.winmd")) }; + var prefix = new[] { "C:\\Node\\node.exe", "cli.js" }; + var config = new JsBindingsConfig { Lang = "js" }; + var args = DynWinrtCodegenService.BuildBulkArgs(prefix, winmds, _temp, config, new List()); + + Assert.AreEqual("C:\\Node\\node.exe", args[0]); + Assert.AreEqual("cli.js", args[1]); + Assert.AreEqual("generate", args[2]); + } + + [TestMethod] + public void BuildExtraTypeArgs_IncludesNamespaceAndClassFlags() + { + var winmds = new[] { new FileInfo(Path.Combine(_temp.FullName, "Windows.winmd")) }; + var extra = new JsBindingsExtraType + { + Namespace = "Windows.Foundation", + Classes = { "Uri", "Calendar" }, + }; + var config = new JsBindingsConfig { Lang = "js" }; + var args = DynWinrtCodegenService.BuildExtraTypeArgs( + Array.Empty(), winmds, _temp, config, new List(), extra); + + Assert.AreEqual("generate", args[0]); + var nsIdx = args.IndexOf("--namespace"); + Assert.IsTrue(nsIdx >= 0); + Assert.AreEqual("Windows.Foundation", args[nsIdx + 1]); + var classIdx = args.IndexOf("--class-name"); + Assert.IsTrue(classIdx >= 0); + Assert.AreEqual("Uri,Calendar", args[classIdx + 1], + "Classes must be comma-joined."); + } + + // extraTypes-only cherry-pick workflow must produce a + // valid argv without an empty --winmd flag. When the user supplies + // refs + extraTypes alone (no bulk emit set), BuildExtraTypeArgs must + // omit --winmd entirely so codegen doesn't see `--winmd ""`. + + [TestMethod] + public void BuildExtraTypeArgs_EmptyEmitWinmds_OmitsWinmdFlag() + { + var refs = new List + { + new(Path.Combine(_temp.FullName, "Vendor.SDK.winmd")), + }; + var extra = new JsBindingsExtraType + { + Namespace = "Vendor.SDK.Camera", + Classes = { "Lens" }, + }; + var config = new JsBindingsConfig { Lang = "js" }; + + var args = DynWinrtCodegenService.BuildExtraTypeArgs( + Array.Empty(), Array.Empty(), _temp, config, refs, extra); + + Assert.IsFalse(args.Contains("--winmd"), + "When emit winmds are empty, --winmd must be omitted entirely (no empty arg)."); + CollectionAssert.Contains(args, "--ref", + "extraTypes-only flow still passes --ref for type resolution."); + CollectionAssert.Contains(args, "--namespace"); + CollectionAssert.Contains(args, "--class-name"); + CollectionAssert.Contains(args, "--lang"); + } + + [TestMethod] + public void BuildExtraTypeArgs_NonEmptyEmitWinmds_IncludesWinmdFlag() + { + // Regression guard: ensure the M2 fix didn't accidentally drop + // --winmd for the normal bulk + extraType combo. + var winmds = new[] { new FileInfo(Path.Combine(_temp.FullName, "Windows.winmd")) }; + var extra = new JsBindingsExtraType + { + Namespace = "Windows.Foundation", + Classes = { "Uri" }, + }; + var config = new JsBindingsConfig { Lang = "js" }; + + var args = DynWinrtCodegenService.BuildExtraTypeArgs( + Array.Empty(), winmds, _temp, config, new List(), extra); + + var winmdIdx = args.IndexOf("--winmd"); + Assert.IsTrue(winmdIdx >= 0, "Non-empty emit winmds must include --winmd."); + Assert.IsTrue(args[winmdIdx + 1].EndsWith("Windows.winmd", StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenInvocationTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenInvocationTests.cs new file mode 100644 index 00000000..ee659ba1 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenInvocationTests.cs @@ -0,0 +1,404 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Runtime.InteropServices; +using WinApp.Cli.Services; + +namespace WinApp.Cli.Tests; + +// split of the historical DynWinrtCodegenServiceTests. +// Scope: ResolveExecutableOnPath / ResolveCodegenInvocation / SpawnCodegen. +[TestClass] +[DoNotParallelize] // CWD/PATH/PATHEXT hijack tests mutate process-wide state. +public class DynWinrtCodegenInvocationTests +{ + public TestContext TestContext { get; set; } = null!; + + private DirectoryInfo _temp = null!; + + [TestInitialize] + public void Init() + { + _temp = new DirectoryInfo(Path.Combine(Path.GetTempPath(), $"DynWinrtCodegenInvocationTests_{Guid.NewGuid():N}")); + _temp.Create(); + } + + [TestCleanup] + public void Cleanup() + { + try { _temp.Delete(recursive: true); } catch { /* ignore */ } + } + + // ------------------------------------------------------------------------- + // ResolveExecutableOnPath — PATH lookup must skip CWD-equivalent entries + // to prevent search-order hijack. + // ------------------------------------------------------------------------- + + [TestMethod] + public void ResolveExecutableOnPath_AbsolutePathIn_PassedThroughWhenExists() + { + var f = new FileInfo(Path.Combine(_temp.FullName, "tool.exe")); + File.WriteAllText(f.FullName, ""); + var resolved = DynWinrtCodegenService.ResolveExecutableOnPath(f.FullName); + Assert.AreEqual(f.FullName, resolved); + } + + [TestMethod] + public void ResolveExecutableOnPath_NonExistent_ReturnsNull() + { + Assert.IsNull(DynWinrtCodegenService.ResolveExecutableOnPath("this-tool-does-not-exist-anywhere")); + } + + [TestMethod] + public void ResolveExecutableOnPath_EmptyInput_ReturnsNull() + { + Assert.IsNull(DynWinrtCodegenService.ResolveExecutableOnPath("")); + Assert.IsNull(DynWinrtCodegenService.ResolveExecutableOnPath(" ")); + } + + [TestMethod] + public void ResolveExecutableOnPath_SkipsLiteralDotAndEmptyPathEntries() + { + var decoy = new DirectoryInfo(Path.Combine(_temp.FullName, "decoy-cwd")); + var safe = new DirectoryInfo(Path.Combine(_temp.FullName, "safe-bin")); + decoy.Create(); + safe.Create(); + var decoyNode = Path.Combine(decoy.FullName, "node.exe"); + var safeNode = Path.Combine(safe.FullName, "node.exe"); + File.WriteAllText(decoyNode, "DECOY"); + File.WriteAllText(safeNode, "SAFE"); + + var prevCwd = Directory.GetCurrentDirectory(); + var prevPath = Environment.GetEnvironmentVariable("PATH"); + var prevExt = Environment.GetEnvironmentVariable("PATHEXT"); + try + { + Directory.SetCurrentDirectory(decoy.FullName); + Environment.SetEnvironmentVariable( + "PATH", + $".{Path.PathSeparator}{Path.PathSeparator}{safe.FullName}"); + Environment.SetEnvironmentVariable("PATHEXT", ".COM;.EXE;.BAT;.CMD"); + + var resolved = DynWinrtCodegenService.ResolveExecutableOnPath("node"); + + Assert.IsNotNull(resolved); + Assert.AreEqual( + Path.GetFullPath(safeNode).ToLowerInvariant(), + Path.GetFullPath(resolved!).ToLowerInvariant(), + $"Expected safe PATH dir to win; got {resolved}"); + } + finally + { + Environment.SetEnvironmentVariable("PATH", prevPath); + Environment.SetEnvironmentVariable("PATHEXT", prevExt); + Directory.SetCurrentDirectory(prevCwd); + } + } + + [TestMethod] + public void ResolveExecutableOnPath_SkipsAbsolutePathEntryThatEqualsCwd() + { + var decoy = new DirectoryInfo(Path.Combine(_temp.FullName, "abs-cwd")); + var safe = new DirectoryInfo(Path.Combine(_temp.FullName, "safe-bin2")); + decoy.Create(); + safe.Create(); + File.WriteAllText(Path.Combine(decoy.FullName, "node.exe"), "DECOY"); + var safeNode = Path.Combine(safe.FullName, "node.exe"); + File.WriteAllText(safeNode, "SAFE"); + + var prevCwd = Directory.GetCurrentDirectory(); + var prevPath = Environment.GetEnvironmentVariable("PATH"); + var prevExt = Environment.GetEnvironmentVariable("PATHEXT"); + try + { + Directory.SetCurrentDirectory(decoy.FullName); + Environment.SetEnvironmentVariable( + "PATH", + $"{decoy.FullName}{Path.PathSeparator}{safe.FullName}"); + Environment.SetEnvironmentVariable("PATHEXT", ".COM;.EXE;.BAT;.CMD"); + + var resolved = DynWinrtCodegenService.ResolveExecutableOnPath("node"); + + Assert.IsNotNull(resolved); + Assert.AreEqual( + Path.GetFullPath(safeNode).ToLowerInvariant(), + Path.GetFullPath(resolved!).ToLowerInvariant(), + "Absolute PATH entry equal to CWD must be skipped to prevent local hijack."); + } + finally + { + Environment.SetEnvironmentVariable("PATH", prevPath); + Environment.SetEnvironmentVariable("PATHEXT", prevExt); + Directory.SetCurrentDirectory(prevCwd); + } + } + + [TestMethod] + public void ResolveExecutableOnPath_HonorsPathExt() + { + var safe = new DirectoryInfo(Path.Combine(_temp.FullName, "safe-cmd")); + safe.Create(); + var cmd = Path.Combine(safe.FullName, "node.cmd"); + File.WriteAllText(cmd, "@echo"); + + var prevPath = Environment.GetEnvironmentVariable("PATH"); + var prevExt = Environment.GetEnvironmentVariable("PATHEXT"); + try + { + Environment.SetEnvironmentVariable("PATH", safe.FullName); + Environment.SetEnvironmentVariable("PATHEXT", ".COM;.EXE;.BAT;.CMD"); + + var resolved = DynWinrtCodegenService.ResolveExecutableOnPath("node"); + + Assert.IsNotNull(resolved); + Assert.AreEqual( + Path.GetFullPath(cmd).ToLowerInvariant(), + Path.GetFullPath(resolved!).ToLowerInvariant()); + } + finally + { + Environment.SetEnvironmentVariable("PATH", prevPath); + Environment.SetEnvironmentVariable("PATHEXT", prevExt); + } + } + + // nativeOnly mode must reject .bat/.cmd/.ps1, which dispatch + // through cmd.exe / pwsh and would re-parse user-derived args. + + [TestMethod] + public void ResolveExecutableOnPath_NativeOnly_RejectsBatAndCmd() + { + var safe = new DirectoryInfo(Path.Combine(_temp.FullName, "safe-native")); + safe.Create(); + // Only a node.cmd is available — nativeOnly must refuse. + var cmd = Path.Combine(safe.FullName, "node.cmd"); + File.WriteAllText(cmd, "@echo"); + + var prevPath = Environment.GetEnvironmentVariable("PATH"); + var prevExt = Environment.GetEnvironmentVariable("PATHEXT"); + try + { + Environment.SetEnvironmentVariable("PATH", safe.FullName); + Environment.SetEnvironmentVariable("PATHEXT", ".COM;.EXE;.BAT;.CMD"); + + var nonStrict = DynWinrtCodegenService.ResolveExecutableOnPath("node"); + Assert.IsNotNull(nonStrict, "Default mode still finds the .cmd via PATHEXT."); + + var nativeOnly = DynWinrtCodegenService.ResolveExecutableOnPath("node", nativeOnly: true); + Assert.IsNull(nativeOnly, "Native-only must skip .cmd to prevent cmd.exe arg re-parsing."); + } + finally + { + Environment.SetEnvironmentVariable("PATH", prevPath); + Environment.SetEnvironmentVariable("PATHEXT", prevExt); + } + } + + [TestMethod] + public void ResolveExecutableOnPath_NativeOnly_BareNameWithCmdExtension_Rejected() + { + // PATH entry contains a literal `node.cmd`; the bare-match path + // (which short-circuits PATHEXT) must still honor nativeOnly. + var safe = new DirectoryInfo(Path.Combine(_temp.FullName, "bare-cmd")); + safe.Create(); + var cmd = Path.Combine(safe.FullName, "node.cmd"); + File.WriteAllText(cmd, "@echo"); + + var prevPath = Environment.GetEnvironmentVariable("PATH"); + var prevExt = Environment.GetEnvironmentVariable("PATHEXT"); + try + { + Environment.SetEnvironmentVariable("PATH", safe.FullName); + Environment.SetEnvironmentVariable("PATHEXT", ".COM;.EXE;.BAT;.CMD"); + + // Passing "node.cmd" as the bare command: bare-match would find + // it, but nativeOnly rejects the .cmd extension. + var resolved = DynWinrtCodegenService.ResolveExecutableOnPath("node.cmd", nativeOnly: true); + Assert.IsNull(resolved, "nativeOnly bare-match must reject .cmd."); + } + finally + { + Environment.SetEnvironmentVariable("PATH", prevPath); + Environment.SetEnvironmentVariable("PATHEXT", prevExt); + } + } + + // ------------------------------------------------------------------------- + // ResolveCodegenInvocation — direct .exe wins; cli.js fallback; + // friendly error when both missing. + // ------------------------------------------------------------------------- + + [TestMethod] + public void ResolveCodegenInvocation_DirectExePreferred() + { + var packageDir = new DirectoryInfo(Path.Combine(_temp.FullName, "node_modules", "@microsoft", "dynwinrt-codegen")); + var arch = RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "arm64" : "x64"; + var binDir = new DirectoryInfo(Path.Combine(packageDir.FullName, "bin", arch)); + binDir.Create(); + var exe = new FileInfo(Path.Combine(binDir.FullName, "dynwinrt-codegen.exe")); + File.WriteAllBytes(exe.FullName, Array.Empty()); + File.WriteAllText(Path.Combine(packageDir.FullName, "cli.js"), "// stub"); + + var (resolved, args) = DynWinrtCodegenService.ResolveCodegenInvocation(_temp); + + Assert.AreEqual(exe.FullName, resolved, "Direct .exe must win over cli.js fallback"); + Assert.AreEqual(0, args.Count, "Direct .exe call passes no prefix args"); + } + + [TestMethod] + public void ResolveCodegenInvocation_CliJsFallback_UsesQualifiedNodePath() + { + var packageDir = new DirectoryInfo(Path.Combine(_temp.FullName, "node_modules", "@microsoft", "dynwinrt-codegen")); + packageDir.Create(); + var cli = new FileInfo(Path.Combine(packageDir.FullName, "cli.js")); + File.WriteAllText(cli.FullName, "// stub"); + + // The fallback now uses nativeOnly=true — only finds node.exe/.com. + var resolvedNode = DynWinrtCodegenService.ResolveExecutableOnPath("node", nativeOnly: true); + if (resolvedNode is null) + { + Assert.ThrowsExactly( + () => DynWinrtCodegenService.ResolveCodegenInvocation(_temp), + "Without a native node executable, the fallback must refuse."); + return; + } + + var (exe, args) = DynWinrtCodegenService.ResolveCodegenInvocation(_temp); + + Assert.AreEqual(resolvedNode, exe, + "Node executable must be the fully-resolved PATH lookup."); + Assert.IsTrue(Path.IsPathRooted(exe), + "Spawned executable path must be absolute to prevent CWD-search hijacks."); + // error message in the fallback path mentions native node. + var ext = Path.GetExtension(exe); + Assert.IsTrue( + ext.Equals(".exe", StringComparison.OrdinalIgnoreCase) + || ext.Equals(".com", StringComparison.OrdinalIgnoreCase), + $"Fallback must spawn a native node executable (.exe/.com), got: {ext}"); + Assert.AreEqual(1, args.Count); + Assert.AreEqual(cli.FullName, args[0]); + } + + [TestMethod] + public void ResolveCodegenInvocation_NothingFound_ThrowsActionableError() + { + var ex = Assert.ThrowsExactly( + () => DynWinrtCodegenService.ResolveCodegenInvocation(_temp)); + + StringAssert.Contains(ex.Message, "@microsoft/dynwinrt-codegen"); + StringAssert.Contains(ex.Message, "@microsoft/winappcli"); + StringAssert.Contains(ex.Message, "yarn berry"); + StringAssert.Contains(ex.Message, "pnpm"); + } + + [TestMethod] + public void ResolveCodegenInvocation_UpwardLookup_FindsHoistedPackage() + { + var repoRoot = _temp; + var nestedWorkspace = repoRoot.CreateSubdirectory("apps").CreateSubdirectory("electron-app"); + + var packageDir = new DirectoryInfo(Path.Combine( + repoRoot.FullName, "node_modules", "@microsoft", "dynwinrt-codegen")); + packageDir.Create(); + var arch = RuntimeInformation.ProcessArchitecture switch + { + Architecture.Arm64 => "arm64", + _ => "x64", + }; + var exe = new FileInfo(Path.Combine(packageDir.FullName, "bin", arch, "dynwinrt-codegen.exe")); + exe.Directory!.Create(); + File.WriteAllText(exe.FullName, "stub"); + + var (resolved, args) = DynWinrtCodegenService.ResolveCodegenInvocation(nestedWorkspace); + + Assert.AreEqual(exe.FullName, resolved, + "Resolver must walk upward from the nested workspace to find the codegen at the repo root."); + Assert.AreEqual(0, args.Count); + } + + [TestMethod] + public void ResolveCodegenInvocation_InnerNodeModulesShadowsOuter() + { + var repoRoot = _temp; + var nestedWorkspace = repoRoot.CreateSubdirectory("apps").CreateSubdirectory("inner"); + var arch = RuntimeInformation.ProcessArchitecture switch + { + Architecture.Arm64 => "arm64", + _ => "x64", + }; + + var outerExe = new FileInfo(Path.Combine( + repoRoot.FullName, "node_modules", "@microsoft", "dynwinrt-codegen", "bin", arch, "dynwinrt-codegen.exe")); + outerExe.Directory!.Create(); + File.WriteAllText(outerExe.FullName, "outer-stub"); + + var innerExe = new FileInfo(Path.Combine( + nestedWorkspace.FullName, "node_modules", "@microsoft", "dynwinrt-codegen", "bin", arch, "dynwinrt-codegen.exe")); + innerExe.Directory!.Create(); + File.WriteAllText(innerExe.FullName, "inner-stub"); + + var (resolved, _) = DynWinrtCodegenService.ResolveCodegenInvocation(nestedWorkspace); + Assert.AreEqual(innerExe.FullName, resolved, + "When package exists at multiple ancestors, the workspace-local one wins."); + } + + // ------------------------------------------------------------------------- + // SpawnCodegen — cancellation kills child process tree promptly. + // ------------------------------------------------------------------------- + + [TestMethod] + public async Task SpawnCodegen_CancellationKillsLongRunningChild_WithoutHang() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Assert.Inconclusive("Windows-only test for ProcessTree kill."); + return; + } + + var cmd = DynWinrtCodegenService.ResolveExecutableOnPath("cmd"); + Assert.IsNotNull(cmd); + + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = cmd!, + ArgumentList = { "/c", "ping", "-n", "60", "127.0.0.1" }, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + + using var cts = new CancellationTokenSource(); + using var p = System.Diagnostics.Process.Start(psi)!; + Assert.IsFalse(p.HasExited, "Child should start running."); + + _ = Task.Run(async () => { await Task.Delay(150); cts.Cancel(); }); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + var caught = false; + try + { + await p.WaitForExitAsync(cts.Token); + } + catch (OperationCanceledException) + { + caught = true; + try + { + if (!p.HasExited) + { + p.Kill(entireProcessTree: true); + using var killCts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + await p.WaitForExitAsync(killCts.Token); + } + } + catch { /* best-effort */ } + } + sw.Stop(); + + Assert.IsTrue(caught, "Cancellation must surface OperationCanceledException."); + Assert.IsTrue(p.HasExited, "Child must be dead after cancel-and-kill."); + Assert.IsTrue(sw.ElapsedMilliseconds < 5_000, + $"Cancel+kill should complete fast; took {sw.ElapsedMilliseconds}ms."); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenOutputSafetyTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenOutputSafetyTests.cs new file mode 100644 index 00000000..701e277d --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenOutputSafetyTests.cs @@ -0,0 +1,250 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Runtime.InteropServices; +using WinApp.Cli.Services; + +namespace WinApp.Cli.Tests; + +// split of the historical DynWinrtCodegenServiceTests. +// Scope: ResolveOutputDir / WipeOutputDirSafely / WriteManagedMarker — +// the "do not destroy user files" safety net. +[TestClass] +public class DynWinrtCodegenOutputSafetyTests +{ + public TestContext TestContext { get; set; } = null!; + + private DirectoryInfo _temp = null!; + + [TestInitialize] + public void Init() + { + _temp = new DirectoryInfo(Path.Combine(Path.GetTempPath(), $"DynWinrtCodegenOutputSafetyTests_{Guid.NewGuid():N}")); + _temp.Create(); + } + + [TestCleanup] + public void Cleanup() + { + try { _temp.Delete(recursive: true); } catch { /* ignore */ } + } + + // ------------------------------------------------------------------------- + // ResolveOutputDir — purely lexical, must not touch disk. + // ------------------------------------------------------------------------- + + [TestMethod] + public void ResolveOutputDir_RelativePath_ResolvedAgainstWorkspace() + { + var dir = DynWinrtCodegenService.ResolveOutputDir(_temp, "bindings/winrt"); + StringAssert.StartsWith(dir.FullName, _temp.FullName); + StringAssert.EndsWith(dir.FullName, Path.Combine("bindings", "winrt")); + } + + [TestMethod] + public void ResolveOutputDir_AbsolutePath_InsideWorkspace_Honored() + { + var abs = Path.Combine(_temp.FullName, "abs", "out"); + var dir = DynWinrtCodegenService.ResolveOutputDir(_temp, abs); + Assert.AreEqual(Path.GetFullPath(abs), dir.FullName); + } + + [TestMethod] + public void ResolveOutputDir_RejectsAbsolutePathOutsideWorkspace() + { + var ex = Assert.ThrowsExactly( + () => DynWinrtCodegenService.ResolveOutputDir(_temp, @"C:\some\other\place")); + StringAssert.Contains(ex.Message, "outside the workspace"); + } + + [TestMethod] + public void ResolveOutputDir_RejectsParentEscape() + { + var ex = Assert.ThrowsExactly( + () => DynWinrtCodegenService.ResolveOutputDir(_temp, "../escape")); + StringAssert.Contains(ex.Message, "outside the workspace"); + } + + [TestMethod] + public void ResolveOutputDir_RejectsWorkspaceRootItself() + { + var ex = Assert.ThrowsExactly( + () => DynWinrtCodegenService.ResolveOutputDir(_temp, _temp.FullName)); + StringAssert.Contains(ex.Message, "outside the workspace"); + } + + // ------------------------------------------------------------------------- + // WipeOutputDirSafely — marker presence is the single safety gate. + // ------------------------------------------------------------------------- + + [TestMethod] + public void WipeOutputDirSafely_NonExistentDir_NoOp() + { + var missing = new DirectoryInfo(Path.Combine(_temp.FullName, "missing")); + DynWinrtCodegenService.WipeOutputDirSafely(missing); + Assert.IsFalse(missing.Exists); + } + + [TestMethod] + public void WipeOutputDirSafely_EmptyDir_NoOpAndPreserved() + { + var empty = new DirectoryInfo(Path.Combine(_temp.FullName, "empty")); + empty.Create(); + DynWinrtCodegenService.WipeOutputDirSafely(empty); + empty.Refresh(); + Assert.IsTrue(empty.Exists); + } + + [TestMethod] + public void WipeOutputDirSafely_NonEmptyWithoutMarker_Throws() + { + var dir = new DirectoryInfo(Path.Combine(_temp.FullName, "user-files")); + dir.Create(); + File.WriteAllText(Path.Combine(dir.FullName, "user.ts"), "// user code"); + + var ex = Assert.ThrowsExactly( + () => DynWinrtCodegenService.WipeOutputDirSafely(dir)); + StringAssert.Contains(ex.Message, DynWinrtCodegenService.ManagedMarkerFileName); + StringAssert.Contains(ex.Message, "Refusing to wipe"); + + Assert.IsTrue(File.Exists(Path.Combine(dir.FullName, "user.ts")), + "User file must be preserved when wipe is refused."); + } + + [TestMethod] + public void WipeOutputDirSafely_NonEmptyWithMarker_DeletesAllChildren() + { + var dir = new DirectoryInfo(Path.Combine(_temp.FullName, "managed")); + dir.Create(); + File.WriteAllText(Path.Combine(dir.FullName, DynWinrtCodegenService.ManagedMarkerFileName), "marker"); + File.WriteAllText(Path.Combine(dir.FullName, "Uri.js"), "// generated"); + var subdir = new DirectoryInfo(Path.Combine(dir.FullName, "sub")); + subdir.Create(); + File.WriteAllText(Path.Combine(subdir.FullName, "Foo.js"), "// generated"); + + DynWinrtCodegenService.WipeOutputDirSafely(dir); + + dir.Refresh(); + Assert.IsTrue(dir.Exists, "Wipe deletes children but preserves the directory itself."); + Assert.AreEqual(0, dir.EnumerateFileSystemInfos().Count(), + "Marker, generated files, and subdirectories must all be removed so the next run starts clean."); + } + + // ------------------------------------------------------------------------- + // WriteManagedMarker — file content is debug-only; presence is the contract. + // ------------------------------------------------------------------------- + + [TestMethod] + public void WriteManagedMarker_CreatesFileNamedDynwinrtManaged() + { + var dir = new DirectoryInfo(Path.Combine(_temp.FullName, "out")); + dir.Create(); + + DynWinrtCodegenService.WriteManagedMarker(dir); + + var marker = new FileInfo(Path.Combine(dir.FullName, DynWinrtCodegenService.ManagedMarkerFileName)); + Assert.IsTrue(marker.Exists); + var body = File.ReadAllText(marker.FullName); + StringAssert.Contains(body, "generated_at:"); + } + + // ------------------------------------------------------------------------- + // Reparse-point (junction / symlink) rejection. Requires Windows + admin + // or developer-mode for symlink creation; tests skip otherwise. + // ------------------------------------------------------------------------- + + internal static bool TryCreateJunction(string linkPath, string targetPath, out string? skipReason) + { + skipReason = null; + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + skipReason = "Junctions are a Windows-only construct."; + return false; + } + try + { + var psi = new System.Diagnostics.ProcessStartInfo("cmd.exe", $"/c mklink /J \"{linkPath}\" \"{targetPath}\"") + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + using var p = System.Diagnostics.Process.Start(psi); + if (p is null) + { + skipReason = "Could not spawn cmd.exe to create junction."; + return false; + } + p.WaitForExit(10_000); + if (p.ExitCode != 0) + { + skipReason = $"mklink failed (exit {p.ExitCode}): {p.StandardError.ReadToEnd().Trim()}"; + return false; + } + return Directory.Exists(linkPath); + } + catch (Exception ex) + { + skipReason = $"Junction creation threw: {ex.Message}"; + return false; + } + } + + [TestMethod] + public void WipeOutputDirSafely_RejectsReparsePointAsOutputDir() + { + var outsideTarget = new DirectoryInfo(Path.Combine(_temp.FullName, "outside")); + outsideTarget.Create(); + var preciousFile = Path.Combine(outsideTarget.FullName, "precious.txt"); + File.WriteAllText(preciousFile, "DO NOT DELETE"); + File.WriteAllText(Path.Combine(outsideTarget.FullName, DynWinrtCodegenService.ManagedMarkerFileName), + "# fake marker — would normally authorise wipe"); + + var workspace = new DirectoryInfo(Path.Combine(_temp.FullName, "ws")); + workspace.Create(); + var junction = Path.Combine(workspace.FullName, "bindings"); + + if (!TryCreateJunction(junction, outsideTarget.FullName, out var skip)) + { + Assert.Inconclusive(skip ?? "Junction creation unavailable in this environment."); + return; + } + + var outputDir = new DirectoryInfo(junction); + var threw = false; + try + { + DynWinrtCodegenService.WipeOutputDirSafely(outputDir); + } + catch (InvalidOperationException) + { + threw = true; + } + + Assert.IsTrue(threw, "Wipe must refuse a reparse-point output dir."); + Assert.IsTrue(File.Exists(preciousFile), + "Precious file behind the junction must remain untouched."); + } + + [TestMethod] + public void ResolveOutputDir_RejectsAncestorReparsePoint() + { + var outside = new DirectoryInfo(Path.Combine(_temp.FullName, "outside")); + outside.Create(); + var workspace = new DirectoryInfo(Path.Combine(_temp.FullName, "workspace")); + workspace.Create(); + var junctionInsideWs = Path.Combine(workspace.FullName, "ws-link"); + + if (!TryCreateJunction(junctionInsideWs, outside.FullName, out var skip)) + { + Assert.Inconclusive(skip ?? "Junction creation unavailable."); + return; + } + + var ex = Assert.ThrowsExactly(() => + DynWinrtCodegenService.ResolveOutputDir(workspace, "ws-link/out")); + StringAssert.Contains(ex.Message, "reparse point", + "Error must call out the reparse-point reason for the rejection."); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenStagingTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenStagingTests.cs new file mode 100644 index 00000000..c46adb11 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenStagingTests.cs @@ -0,0 +1,185 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.Services; + +namespace WinApp.Cli.Tests; + +// split of the historical DynWinrtCodegenServiceTests. +// Scope: RunWithStagingAsync — staging/swap failure-safety contract. +[TestClass] +public class DynWinrtCodegenStagingTests +{ + public TestContext TestContext { get; set; } = null!; + + private DirectoryInfo _temp = null!; + + [TestInitialize] + public void Init() + { + _temp = new DirectoryInfo(Path.Combine(Path.GetTempPath(), $"DynWinrtCodegenStagingTests_{Guid.NewGuid():N}")); + _temp.Create(); + } + + [TestCleanup] + public void Cleanup() + { + try { _temp.Delete(recursive: true); } catch { /* ignore */ } + } + + [TestMethod] + public async Task RunWithStagingAsync_Success_SwapsStagingIntoOutputDir() + { + var outputDir = new DirectoryInfo(Path.Combine(_temp.FullName, "out")); + + await DynWinrtCodegenService.RunWithStagingAsync(outputDir, stagingDir => + { + File.WriteAllText(Path.Combine(stagingDir.FullName, "Foo.js"), "// stub"); + File.WriteAllText(Path.Combine(stagingDir.FullName, "Bar.js"), "// stub"); + return Task.CompletedTask; + }); + + outputDir.Refresh(); + Assert.IsTrue(outputDir.Exists, "Output dir must exist after success"); + Assert.IsTrue(File.Exists(Path.Combine(outputDir.FullName, "Foo.js"))); + Assert.IsTrue(File.Exists(Path.Combine(outputDir.FullName, "Bar.js"))); + Assert.IsTrue(File.Exists(Path.Combine(outputDir.FullName, DynWinrtCodegenService.ManagedMarkerFileName)), + "Marker must be present so subsequent runs are allowed to wipe."); + + var leftovers = outputDir.Parent! + .EnumerateDirectories($"{outputDir.Name}.staging.*") + .ToList(); + Assert.AreEqual(0, leftovers.Count, + $"Staging dirs must be cleaned up; found: {string.Join(", ", leftovers.Select(d => d.Name))}"); + } + + [TestMethod] + public async Task RunWithStagingAsync_Failure_PreservesOldOutputAndCleansStaging() + { + var outputDir = new DirectoryInfo(Path.Combine(_temp.FullName, "out")); + outputDir.Create(); + + File.WriteAllText(Path.Combine(outputDir.FullName, "PrevBinding.js"), "// previous output"); + File.WriteAllText(Path.Combine(outputDir.FullName, DynWinrtCodegenService.ManagedMarkerFileName), + "# managed"); + + await Assert.ThrowsExactlyAsync(() => + DynWinrtCodegenService.RunWithStagingAsync(outputDir, stagingDir => + { + File.WriteAllText(Path.Combine(stagingDir.FullName, "Half.js"), "// half-written"); + throw new InvalidOperationException("simulated codegen crash"); + })); + + outputDir.Refresh(); + Assert.IsTrue(outputDir.Exists, "Previous output dir must be preserved on failure."); + Assert.IsTrue(File.Exists(Path.Combine(outputDir.FullName, "PrevBinding.js")), + "Previous bindings must survive a failed regeneration — this is the whole point of staging."); + Assert.IsFalse(File.Exists(Path.Combine(outputDir.FullName, "Half.js")), + "Half-written staging file must NOT bleed into the output dir."); + + var leftovers = outputDir.Parent! + .EnumerateDirectories($"{outputDir.Name}.staging.*") + .ToList(); + Assert.AreEqual(0, leftovers.Count, + $"Staging dirs must be cleaned up after failure; found: {string.Join(", ", leftovers.Select(d => d.Name))}"); + } + + [TestMethod] + public async Task RunWithStagingAsync_PreservesContentsOnSuccess() + { + var outputDir = new DirectoryInfo(Path.Combine(_temp.FullName, "out")); + + await DynWinrtCodegenService.RunWithStagingAsync(outputDir, stagingDir => + { + for (int i = 0; i < 10; i++) + { + File.WriteAllText(Path.Combine(stagingDir.FullName, $"F{i}.js"), $"// {i}"); + } + Directory.CreateDirectory(Path.Combine(stagingDir.FullName, "sub")); + File.WriteAllText(Path.Combine(stagingDir.FullName, "sub", "deep.js"), "// nested"); + return Task.CompletedTask; + }); + + outputDir.Refresh(); + var jsFiles = outputDir.EnumerateFiles("*.js").Select(f => f.Name).OrderBy(n => n).ToList(); + Assert.AreEqual(10, jsFiles.Count, "All 10 .js files from staging must land in output dir."); + Assert.IsTrue(File.Exists(Path.Combine(outputDir.FullName, "sub", "deep.js")), + "Nested files in staging must survive the swap."); + } + + [TestMethod] + public async Task RunWithStagingAsync_OldOutputWithoutMarker_Throws_StagingCleaned() + { + var outputDir = new DirectoryInfo(Path.Combine(_temp.FullName, "out")); + outputDir.Create(); + File.WriteAllText(Path.Combine(outputDir.FullName, "user-handwritten.js"), "important"); + + await Assert.ThrowsExactlyAsync(() => + DynWinrtCodegenService.RunWithStagingAsync(outputDir, stagingDir => + { + File.WriteAllText(Path.Combine(stagingDir.FullName, "Generated.js"), "// new"); + return Task.CompletedTask; + })); + + Assert.IsTrue(File.Exists(Path.Combine(outputDir.FullName, "user-handwritten.js")), + "Non-managed user file must survive — WipeOutputDirSafely refused."); + + var leftovers = outputDir.Parent! + .EnumerateDirectories($"{outputDir.Name}.staging.*") + .ToList(); + Assert.AreEqual(0, leftovers.Count, + $"Staging dirs must be cleaned up even when swap fails; found: {string.Join(", ", leftovers.Select(d => d.Name))}"); + } + + // Failure during the swap step — backup restore succeeds; everything cleaned up. + [TestMethod] + public async Task RunWithStagingAsync_SwapStepFailure_RestoresOldOutputAndCleansStaging() + { + var outputDir = new DirectoryInfo(Path.Combine(_temp.FullName, "out")); + outputDir.Create(); + File.WriteAllText(Path.Combine(outputDir.FullName, "Prev.js"), "// prev"); + File.WriteAllText(Path.Combine(outputDir.FullName, DynWinrtCodegenService.ManagedMarkerFileName), + "# managed"); + + var lockPath = Path.Combine(outputDir.FullName, "lock-file"); + using (var blocker = new FileStream(lockPath, FileMode.Create, FileAccess.Write, FileShare.None)) + { + await Assert.ThrowsExactlyAsync(async () => + await DynWinrtCodegenService.RunWithStagingAsync(outputDir, stagingDir => + { + File.WriteAllText(Path.Combine(stagingDir.FullName, "New.js"), "// new"); + return Task.CompletedTask; + })); + } + + var stagingLeftovers = outputDir.Parent! + .EnumerateDirectories($"{outputDir.Name}.staging.*") + .ToList(); + Assert.AreEqual(0, stagingLeftovers.Count, + "Staging must be cleaned up after a swap-step failure."); + + var backupLeftovers = outputDir.Parent! + .EnumerateDirectories($"{outputDir.Name}.backup.*") + .ToList(); + Assert.AreEqual(0, backupLeftovers.Count, + "Backup must be cleaned up after a swap-step failure."); + + outputDir.Refresh(); + Assert.IsTrue(outputDir.Exists, "Old output dir must still exist."); + Assert.IsTrue(File.Exists(Path.Combine(outputDir.FullName, "Prev.js")), + "Previous bindings must be preserved — that's the whole point of staging."); + } + + // the catch block in RunWithStagingAsync preserves the + // backup directory on disk when the restore Move also fails, and + // surfaces the preserved path in the thrown IOException so the user + // can recover manually. + // + // This branch cannot be exercised deterministically without + // file-system hooks: triggering it would require both the + // staging→outputDir Move AND the backup→outputDir restore Move to + // fail in sequence within the same call, which is essentially + // impossible to inject from outside the function. Coverage is + // delegated to code review of the catch block — the behavior is + // mechanically verified there. See review #4 M1. +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/GenerateJsBindingsCommandTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/GenerateJsBindingsCommandTests.cs new file mode 100644 index 00000000..96dd366c --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/GenerateJsBindingsCommandTests.cs @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.Commands; + +namespace WinApp.Cli.Tests; + +// Tests for `node jsbindings generate` — read-only codegen against the +// existing winapp.yaml. Mirrors AddJsBindingsCommandTests structure. +// Codegen result is not asserted (no real NuGet cache); we assert yaml +// is NOT mutated and the npm-shim gate is enforced. +[TestClass] +[DoNotParallelize] +public class GenerateJsBindingsCommandTests : BaseCommandTests +{ + private string? _savedCaller; + + [TestInitialize] + public void TestSetup() + { + _savedCaller = Environment.GetEnvironmentVariable("WINAPP_CLI_CALLER"); + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); + } + + [TestCleanup] + public void TestTeardown() + { + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", _savedCaller); + } + + private async Task<(string ConfigPath, string Content)> WriteYamlWithJsBindingsAsync(string output = "bindings/winrt") + { + var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); + var content = + "packages:\n" + + " - name: Microsoft.WindowsAppSDK\n" + + " version: 1.8.39\n" + + "jsBindings:\n" + + $" output: {output}\n" + + " lang: js\n"; + await File.WriteAllTextAsync(configPath, content); + return (configPath, content); + } + + [TestMethod] + public async Task Generate_WithoutNpmCaller_ExitsWithActionableError() + { + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", null); + await WriteYamlWithJsBindingsAsync(); + + var cmd = GetRequiredService(); + var args = new[] { _tempDirectory.FullName }; + + var exitCode = await ParseAndInvokeWithCaptureAsync(cmd, args); + + Assert.AreEqual(1, exitCode); + var stderr = ConsoleStdErr.ToString(); + StringAssert.Contains(stderr, "'node jsbindings generate' requires the @microsoft/winappcli npm package"); + StringAssert.Contains(stderr, "npx winapp node jsbindings generate"); + } + + [TestMethod] + public async Task Generate_NoYaml_ReturnsErrorWithInitHint() + { + // No yaml at all → tell the user to init first. + var cmd = GetRequiredService(); + var args = new[] { _tempDirectory.FullName }; + + var exitCode = await ParseAndInvokeWithCaptureAsync(cmd, args); + + Assert.AreEqual(1, exitCode); + var stderr = ConsoleStdErr.ToString(); + StringAssert.Contains(stderr, "winapp.yaml not found"); + } + + [TestMethod] + public async Task Generate_YamlWithoutJsBindingsBlock_FailsWithAddHint() + { + // yaml exists but has no jsBindings: block — point the user at + // `node jsbindings add` to declare one first. + var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); + await File.WriteAllTextAsync(configPath, + "packages:\n" + + " - name: Microsoft.WindowsAppSDK\n" + + " version: 1.8.39\n"); + var originalContent = await File.ReadAllTextAsync(configPath); + + var cmd = GetRequiredService(); + var args = new[] { _tempDirectory.FullName }; + + var exitCode = await ParseAndInvokeWithCaptureAsync(cmd, args); + + Assert.AreEqual(1, exitCode); + var stderr = ConsoleStdErr.ToString(); + StringAssert.Contains(stderr, "No jsBindings: block", + "Error must call out the missing block."); + StringAssert.Contains(stderr, "node jsbindings add", + "Error must point users at the add command."); + + var after = await File.ReadAllTextAsync(configPath); + Assert.AreEqual(originalContent, after, + "Yaml must remain byte-identical when generate refuses."); + } + + [TestMethod] + public async Task Generate_WithExistingJsBindings_DoesNotMutateYaml() + { + // Happy-ish path: yaml has jsBindings block. Codegen will fail with + // "no winmds" (no NuGet cache in test env), but we only assert + // that the yaml is NOT mutated regardless of codegen outcome. + var (configPath, originalContent) = await WriteYamlWithJsBindingsAsync("generated-js"); + + var cmd = GetRequiredService(); + var args = new[] { _tempDirectory.FullName }; + + await ParseAndInvokeWithCaptureAsync(cmd, args); + + var after = await File.ReadAllTextAsync(configPath); + Assert.AreEqual(originalContent, after, + "generate is read-only on yaml — file must be byte-identical."); + } + + [TestMethod] + public async Task Generate_RoutesViaWinAppRootCommand() + { + // Verify the actual command tree exposes `node jsbindings generate`. + await WriteYamlWithJsBindingsAsync(); + + var rootCmd = GetRequiredService(); + var args = new[] { "node", "jsbindings", "generate", _tempDirectory.FullName }; + + var parseResult = rootCmd.Parse(args); + Assert.AreEqual(0, parseResult.Errors.Count, + "Parse errors: " + string.Join("; ", parseResult.Errors.Select(e => e.Message))); + Assert.IsInstanceOfType(parseResult.CommandResult.Command, + "`node jsbindings generate` must route to GenerateJsBindingsCommand."); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InitCommandTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InitCommandTests.cs index 27b7055e..0ec11e9d 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InitCommandTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InitCommandTests.cs @@ -4,12 +4,11 @@ using Microsoft.Extensions.DependencyInjection; using WinApp.Cli.Commands; using WinApp.Cli.Services; +using WinApp.Cli.Tests.TestDoubles; namespace WinApp.Cli.Tests; -/// -/// Tests for the InitCommand including SDK installation mode handling -/// +// Tests for the InitCommand including SDK installation mode handling [TestClass] public class InitCommandTests : BaseCommandTests { @@ -140,3 +139,493 @@ public async Task InitCommand_DoesNotGenerateCertificate() Assert.IsFalse(File.Exists(certPath), "Init should not generate devcert.pfx - certificates should be generated separately with 'cert generate'"); } } + +// --js-bindings flag gating — only allowed from the npm shim +// (WINAPP_CLI_CALLER=nodejs-package). [DoNotParallelize] because tests +// mutate that process-wide env var. +[TestClass] +[DoNotParallelize] +public class InitCommandJsBindingsTests : BaseCommandTests +{ + private string? _savedCaller; + + [TestInitialize] + public void TestSetup() + { + _savedCaller = Environment.GetEnvironmentVariable("WINAPP_CLI_CALLER"); + } + + [TestCleanup] + public void TestTeardown() + { + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", _savedCaller); + } + + [TestMethod] + public async Task InitCommand_WithJsBindingsAndWingetCaller_ExitsWithActionableError() + { + // Arrange — simulate winget invocation: env var unset. + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", null); + + var initCommand = GetRequiredService(); + var args = new[] { _tempDirectory.FullName, "--config-only", "--js-bindings" }; + + // Act + var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); + + // Assert + Assert.AreEqual(1, exitCode, "winget caller passing --js-bindings should exit with code 1"); + + var stderr = ConsoleStdErr.ToString(); + StringAssert.Contains(stderr, "--js-bindings requires the @microsoft/winappcli npm package", + "Error must name the flag and the required package"); + StringAssert.Contains(stderr, "npm i -D @microsoft/winappcli", + "Error must include the recovery command"); + StringAssert.Contains(stderr, "npx winapp init --js-bindings", + "Error must show the post-install invocation"); + + // No yaml should have been written — we bailed before InitializeConfigurationAsync ran. + var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); + Assert.IsFalse(File.Exists(configPath), "winapp.yaml should not be written when init bails early"); + } + + [TestMethod] + public async Task InitCommand_WithJsBindingsAndVscodeCaller_ExitsWithActionableError() + { + // Arrange — VSCode extension is also a Node host but is not the npm + // shim that ships dynwinrt-codegen as a transitive dep, so it is + // explicitly NOT allowed (matches design choice 3=b). + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "vscode-extension"); + + var initCommand = GetRequiredService(); + var args = new[] { _tempDirectory.FullName, "--config-only", "--js-bindings" }; + + var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); + + Assert.AreEqual(1, exitCode, "vscode-extension caller passing --js-bindings should exit 1"); + StringAssert.Contains(ConsoleStdErr.ToString(), "--js-bindings requires the @microsoft/winappcli npm package"); + } + + [TestMethod] + public async Task InitCommand_WithJsBindingsAndNpmCaller_AddsJsBindingsBlockToConfig() + { + // Arrange — simulate npm shim invocation. Pre-create a package.json in + // the workspace so we exercise the v1.2 happy-path that adds + // @microsoft/dynwinrt to dependencies. + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); + var packageJsonPath = Path.Combine(_tempDirectory.FullName, "package.json"); + await File.WriteAllTextAsync(packageJsonPath, + "{\n \"name\": \"my-app\",\n \"version\": \"1.0.0\"\n}\n"); + + var initCommand = GetRequiredService(); + var args = new[] { _tempDirectory.FullName, "--config-only", "--js-bindings" }; + + // Act + var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); + + // Assert + Assert.AreEqual(0, exitCode, "npm caller with --js-bindings should succeed"); + + var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); + Assert.IsTrue(File.Exists(configPath), $"winapp.yaml should be written at {configPath}"); + + var configContent = await File.ReadAllTextAsync(configPath); + StringAssert.Contains(configContent, "packages:", "Standard packages section should still be written"); + StringAssert.Contains(configContent, "jsBindings:", "jsBindings: block should be injected by --js-bindings"); + StringAssert.Contains(configContent, "lang: js", "Default lang=js should be persisted"); + // XAML denylisting now lives in the codegen, not yaml. + StringAssert.DoesNotMatch(configContent, new System.Text.RegularExpressions.Regex(@"excludeNamespacePrefixes\s*:"), + "Default jsBindings yaml must not emit the deprecated excludeNamespacePrefixes block."); + + // @microsoft/dynwinrt must be a runtime dep so `npm ci --omit=dev` works. + var packageJsonContent = await File.ReadAllTextAsync(packageJsonPath); + StringAssert.Contains(packageJsonContent, "@microsoft/dynwinrt", + "package.json should now contain @microsoft/dynwinrt"); + StringAssert.Contains(packageJsonContent, "0.0.0-test", + "Pinned version from FakeNpmWrapperVersionProvider should be written"); + StringAssert.Contains(packageJsonContent, "\"dependencies\"", + "Dependency must be added under dependencies (not devDependencies)"); + } + + [TestMethod] + public async Task InitCommand_WithJsBindingsAndNpmCaller_NoPackageJson_StillSucceeds() + { + // No package.json: don't fail, don't synthesize one — just skip the + // dep edit with a warning. + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); + + var initCommand = GetRequiredService(); + var args = new[] { _tempDirectory.FullName, "--config-only", "--js-bindings" }; + + var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); + + Assert.AreEqual(0, exitCode, "Missing package.json must not fail init"); + var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); + Assert.IsTrue(File.Exists(configPath), "winapp.yaml should still be written"); + StringAssert.Contains(await File.ReadAllTextAsync(configPath), "jsBindings:"); + Assert.IsFalse( + File.Exists(Path.Combine(_tempDirectory.FullName, "package.json")), + "We must not synthesize a package.json on the user's behalf"); + } + + [TestMethod] + public async Task InitCommand_WithoutJsBindingsFlag_DoesNotAddJsBindingsBlock() + { + // Arrange — even with npm caller set, omitting the flag must not + // inject jsBindings (design choice 2=a: opt-in only, no auto-detect). + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); + + var initCommand = GetRequiredService(); + var args = new[] { _tempDirectory.FullName, "--config-only" }; + + var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); + + Assert.AreEqual(0, exitCode); + var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); + var configContent = await File.ReadAllTextAsync(configPath); + Assert.DoesNotContain("jsBindings:", configContent, + "Without --js-bindings, no jsBindings block should be added even when npm-invoked"); + } + + // ------------------------------------------------------------------------- + // Q4: --js-bindings-output / --js-bindings-lang / --js-bindings-only flags + // ------------------------------------------------------------------------- + + [TestMethod] + public async Task InitCommand_WithJsBindingsOutput_OverridesDefaultOutputDir() + { + // Arrange + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); + + var initCommand = GetRequiredService(); + var args = new[] + { + _tempDirectory.FullName, + "--config-only", + "--js-bindings", + "--js-bindings-output", "src/generated/winrt", + }; + + // Act + var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); + + // Assert + Assert.AreEqual(0, exitCode); + var configContent = await File.ReadAllTextAsync(Path.Combine(_tempDirectory.FullName, "winapp.yaml")); + StringAssert.Contains(configContent, "output: src/generated/winrt", + "--js-bindings-output must override the default 'bindings/winrt' in the persisted yaml"); + // Make sure we did not double-write a default output line as well. + Assert.DoesNotContain("output: bindings/winrt", configContent, + "Default output must be replaced, not appended"); + } + + [TestMethod] + public async Task InitCommand_JsBindingsSubOptionsWithoutFlag_FailsAsInvalidUsage() + { + // sub-options without --js-bindings are invalid + // usage (they'd silently no-op while init reports success — bad + // UX). Treat as exit 1 with a clear error message. + // Alias flags (--js-bindings-{preset}) bypass this — they imply the parent. + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); + + var initCommand = GetRequiredService(); + var args = new[] + { + _tempDirectory.FullName, + "--config-only", + "--js-bindings-output", "src/g", + }; + + var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); + + Assert.AreEqual(1, exitCode, + "Sub-options without --js-bindings must fail loudly (exit 1) rather than silently no-op."); + var stderr = ConsoleStdErr.ToString(); + StringAssert.Contains(stderr, "require --js-bindings", + "Error must spell out the dependency on --js-bindings."); + StringAssert.Contains(stderr, "Error:", + "Should be surfaced as an Error, not a Warning."); + + var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); + if (File.Exists(configPath)) + { + var configContent = await File.ReadAllTextAsync(configPath); + Assert.DoesNotContain("jsBindings:", configContent, + "yaml must not gain a jsBindings block when init failed with invalid usage."); + } + } + + // --js-bindings-{preset} alias flags — each implies --js-bindings; + // multiple aliases union their package sets. + + [TestMethod] + public async Task InitCommand_WithJsBindingsAiAlias_ImpliesParentAndAppliesAiPackages() + { + // `--js-bindings-ai` alone (no `--js-bindings`) must apply the AI + // preset (expands to Microsoft.WindowsAppSDK.AI). + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); + + var initCommand = GetRequiredService(); + var args = new[] + { + _tempDirectory.FullName, + "--config-only", + "--js-bindings-ai", + }; + + var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); + + Assert.AreEqual(0, exitCode); + var configContent = await File.ReadAllTextAsync(Path.Combine(_tempDirectory.FullName, "winapp.yaml")); + StringAssert.Contains(configContent, "jsBindings:", + "Alias must imply --js-bindings, so the jsBindings block is created"); + StringAssert.Contains(configContent, "packages:", + "AI preset (v2.0) writes a packages: list under jsBindings"); + foreach (var pkg in JsBindingsPresets.KnownPresets["ai"]) + { + StringAssert.Contains(configContent, pkg, + $"AI preset package id {pkg} must appear in the persisted yaml"); + } + } + + [TestMethod] + public async Task InitCommand_AliasFlagAlone_DoesNotTriggerSubOptionWarning() + { + // Regression guard: aliases imply --js-bindings, so the + // "sub-options without parent" warning must NOT fire when only an + // alias is given (without --js-bindings). + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); + + var initCommand = GetRequiredService(); + var args = new[] + { + _tempDirectory.FullName, + "--config-only", + "--js-bindings-ai", + }; + + var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); + + Assert.AreEqual(0, exitCode); + var stderr = ConsoleStdErr.ToString(); + Assert.IsFalse(stderr.Contains("require --js-bindings"), + "Alias implies --js-bindings; the invalid-usage error must not be printed."); + Assert.IsFalse(stderr.Contains("have no effect without --js-bindings"), + "Legacy warning text must not appear either (kept in case of partial revert)."); + } + + [TestMethod] + public async Task InitCommand_AllPresetAliasesRegistered() + { + // Meta-test: each KnownPreset must have a corresponding registered + // CLI flag. Catches regressions if someone adds a preset to the dict + // but breaks the auto-registration loop in InitCommand's static ctor. + foreach (var preset in JsBindingsPresets.KnownPresets.Keys) + { + Assert.IsTrue( + InitCommand.JsBindingsPresetAliasOptions.ContainsKey(preset), + $"Missing alias option for preset '{preset}'"); + var flag = JsBindingsPresets.AliasFlagName(preset); + var option = InitCommand.JsBindingsPresetAliasOptions[preset]; + CollectionAssert.Contains(option.Aliases.Concat(new[] { option.Name }).ToList(), flag, + $"Option for '{preset}' must surface as '{flag}' on the CLI"); + } + } + + // Re-running init with --js-bindings on an existing yaml ADDS the + // jsBindings block without touching the existing packages: list. + + [TestMethod] + public async Task InitCommand_OnExistingConfig_WithJsBindingsFlag_AddsBlockAndPreservesPackages() + { + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); + var existing = """ + packages: + - name: Microsoft.WindowsAppSDK + version: 1.8.39 + - name: Microsoft.Windows.SDK.BuildTools + version: 10.0.26100.5040 + """; + var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); + await File.WriteAllTextAsync(configPath, existing); + + var initCommand = GetRequiredService(); + var args = new[] { _tempDirectory.FullName, "--config-only", "--js-bindings", "--use-defaults" }; + + // Act + var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); + + // Assert + Assert.AreEqual(0, exitCode, "Re-init with --js-bindings on existing yaml must succeed"); + + var configContent = await File.ReadAllTextAsync(configPath); + // Packages preserved + StringAssert.Contains(configContent, "Microsoft.WindowsAppSDK", + "Re-init must NOT lose previously pinned packages"); + StringAssert.Contains(configContent, "1.8.39", + "Pinned package version must survive re-init"); + StringAssert.Contains(configContent, "Microsoft.Windows.SDK.BuildTools", + "Second pinned package must also survive re-init"); + // jsBindings block was added + StringAssert.Contains(configContent, "jsBindings:", + "Re-init with --js-bindings must add the jsBindings block"); + StringAssert.Contains(configContent, "lang: js", + "Re-init must persist default lang=js"); + } + + // --js-bindings + --setup-sdks none is rejected at + // SetupWorkspaceAsync entry. Verify with the npm shim caller set + // (otherwise the npm-only gate fires first). + [TestMethod] + public async Task InitCommand_WithJsBindingsAndSetupSdksNone_RejectedWithActionableError() + { + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); + + var initCommand = GetRequiredService(); + var args = new[] { _tempDirectory.FullName, "--js-bindings", "--setup-sdks", "none" }; + + var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); + + Assert.AreEqual(1, exitCode, + "--js-bindings + --setup-sdks none must exit 1 (no SDKs → nothing to scan for winmd)."); + var combined = ConsoleStdErr.ToString() + ConsoleStdOut.ToString() + TestAnsiConsole.Output; + Assert.IsTrue( + combined.Contains("--setup-sdks none", StringComparison.OrdinalIgnoreCase) + && combined.Contains("requires SDK packages", StringComparison.OrdinalIgnoreCase), + $"Error must call out the setup-sdks=none conflict. Combined output: {combined}"); + + // Yaml must not gain a jsBindings: block when the guard rejects. + var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); + if (File.Exists(configPath)) + { + var content = await File.ReadAllTextAsync(configPath); + Assert.IsFalse(content.Contains("jsBindings:"), + "Rejected init must NOT write a jsBindings: block."); + } + } + + // --js-bindings is unsupported on .NET (.csproj) projects. + [TestMethod] + public async Task InitCommand_WithJsBindingsOnDotNetProject_RejectedWithActionableError() + { + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); + + // Seed a minimal .csproj so dotNetService.FindCsproj returns 1. + var csprojPath = Path.Combine(_tempDirectory.FullName, "Sample.csproj"); + await File.WriteAllTextAsync(csprojPath, + "\n" + + " \n" + + " Exe\n" + + " net10.0-windows10.0.26100.0\n" + + " \n" + + "\n"); + + var initCommand = GetRequiredService(); + var args = new[] { _tempDirectory.FullName, "--js-bindings", "--use-defaults" }; + + var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); + + Assert.AreEqual(1, exitCode, + "--js-bindings on a .NET project must exit 1 (codegen target is Node/native, not .NET)."); + var combined = ConsoleStdErr.ToString() + ConsoleStdOut.ToString() + TestAnsiConsole.Output; + Assert.IsTrue( + combined.Contains(".NET", StringComparison.OrdinalIgnoreCase) + && combined.Contains("not supported", StringComparison.OrdinalIgnoreCase), + $"Error must call out the .NET-not-supported case. Combined output: {combined}"); + + // Yaml must not be mutated. + var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); + if (File.Exists(configPath)) + { + var content = await File.ReadAllTextAsync(configPath); + Assert.IsFalse(content.Contains("jsBindings:"), + "Rejected init on .NET project must NOT write a jsBindings: block."); + } + } +} + +// init --js-bindings* path that injects the runtime dep via the +// extracted IJsBindingsWorkspaceService. Uses a fake service to verify the +// init→orchestration wiring without spawning real codegen. +[TestClass] +[DoNotParallelize] +public class InitCommandJsBindingsWiringTests : BaseCommandTests +{ + private FakeJsBindingsWorkspaceService _fakeJsBindings = null!; + private string? _savedCaller; + + protected override IServiceCollection ConfigureServices(IServiceCollection services) + { + _fakeJsBindings = new FakeJsBindingsWorkspaceService(); + var existing = services.FirstOrDefault(d => d.ServiceType == typeof(IJsBindingsWorkspaceService)); + if (existing is not null) services.Remove(existing); + services.AddSingleton(_fakeJsBindings); + return services; + } + + [TestInitialize] + public void TestSetup() + { + _savedCaller = Environment.GetEnvironmentVariable("WINAPP_CLI_CALLER"); + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); + } + + [TestCleanup] + public void TestTeardown() + { + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", _savedCaller); + } + + [TestMethod] + public async Task InitCommand_WithJsBindings_InvokesEnsureRuntimeDependencyOnJsBindingsService() + { + // init --js-bindings (config-only) must route through the + // extracted IJsBindingsWorkspaceService for runtime-dep injection. + File.WriteAllText( + Path.Combine(_tempDirectory.FullName, "package.json"), + """{"name":"app","version":"1.0.0","dependencies":{}}"""); + + var initCommand = GetRequiredService(); + var args = new[] { _tempDirectory.FullName, "--config-only", "--js-bindings" }; + var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); + + Assert.AreEqual(0, exitCode); + Assert.IsTrue(_fakeJsBindings.EnsureRuntimeDependencyCalled, + "init --js-bindings must call IJsBindingsWorkspaceService.EnsureRuntimeDependencyAndPrintHint."); + } + + [TestMethod] + public async Task InitCommand_WithJsBindings_JsBindingsRunAsyncFailure_PropagatesNonZeroExit() + { + // Verifies the fake JsBindings service surfaces its non-zero exit + // when invoked directly. End-to-end SetupWorkspaceAsync propagation + // is covered by WorkspaceSetupServiceJsBindingsStepTests. + _fakeJsBindings.Result = new JsBindingsOrchestrationResult + { + ExitCode = 7, + Message = "simulated failure", + }; + + File.WriteAllText(Path.Combine(_tempDirectory.FullName, "winapp.yaml"), + "packages:\n - name: Microsoft.WindowsAppSDK\n version: 1.8.39\n"); + File.WriteAllText( + Path.Combine(_tempDirectory.FullName, "package.json"), + """{"name":"app","version":"1.0.0","dependencies":{}}"""); + + var addCmd = GetRequiredService(); + // The fake's AddAsync stub returns 0, but our specific point is to + // verify the RunAsync result wiring through orchestration. Set up + // the fake's Result and call RunAsync directly to confirm propagation. + var ctx = new JsBindingsOrchestrationContext + { + JsBindingsConfig = new Models.JsBindingsConfig { Output = "bindings/winrt" }, + WinappConfig = new Models.WinappConfig(), + WorkspaceDir = _tempDirectory, + LocalWinappDir = _tempDirectory.CreateSubdirectory(".winapp"), + NugetCacheDir = _tempDirectory, + }; + var result = await _fakeJsBindings.RunAsync(ctx, default!, CancellationToken.None); + Assert.AreEqual(7, result.ExitCode, + "Fake RunAsync must return the configured non-zero exit code (propagation contract)."); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/JsBindingsPresetsTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/JsBindingsPresetsTests.cs new file mode 100644 index 00000000..0811e398 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/JsBindingsPresetsTests.cs @@ -0,0 +1,411 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.Services; + +namespace WinApp.Cli.Tests; + +[TestClass] +public class JsBindingsPresetsTests +{ + [TestMethod] + public void KnownPresets_AiPreset_MapsToAiPackage() + { + Assert.IsTrue(JsBindingsPresets.TryResolve("ai", out var packages)); + CollectionAssert.AreEqual( + new[] { "Microsoft.WindowsAppSDK.AI" }, + packages.ToList(), + "AI preset must map to the Microsoft.WindowsAppSDK.AI NuGet package"); + } + + [TestMethod] + public void KnownPresets_OnlyShipsAi() + { + // Only 'ai' ships today; trip this test when a new one lands. + CollectionAssert.AreEqual( + new[] { "ai" }, + JsBindingsPresets.KnownPresets.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase).ToList(), + "Unexpected preset registered. If intentional, update this test and docs/js-bindings.md."); + } + + [TestMethod] + public void TryResolve_IsCaseInsensitive() + { + Assert.IsTrue(JsBindingsPresets.TryResolve("AI", out var ai)); + Assert.AreEqual(1, ai.Count); + Assert.IsTrue(JsBindingsPresets.TryResolve("Ai", out var aiMixed)); + Assert.AreEqual(1, aiMixed.Count); + } + + [TestMethod] + public void TryResolve_UnknownPreset_ReturnsFalseAndEmpty() + { + Assert.IsFalse(JsBindingsPresets.TryResolve("unknown-xyz", out var packages)); + Assert.AreEqual(0, packages.Count); + } + + [TestMethod] + public void KnownPresetsDisplay_IsAlphabeticallyOrdered() + { + var display = JsBindingsPresets.KnownPresetsDisplay(); + Assert.AreEqual("ai", display, + "Display must be alphabetical so help output is stable"); + } + + // ResolveAndUnion — multi-preset union (only 'ai' ships today). + + [TestMethod] + public void ResolveAndUnion_EmptyInput_ReturnsEmpty() + { + var result = JsBindingsPresets.ResolveAndUnion(Array.Empty()); + Assert.AreEqual(0, result.Count); + } + + [TestMethod] + public void ResolveAndUnion_SinglePreset_EquivalentToTryResolve() + { + var result = JsBindingsPresets.ResolveAndUnion(new[] { "ai" }); + CollectionAssert.AreEqual( + new[] { "Microsoft.WindowsAppSDK.AI" }, + result.ToList(), + "Single-preset union must produce the preset's package IDs in declaration order"); + } + + [TestMethod] + public void ResolveAndUnion_DuplicatePresets_DedupesPackageIds() + { + // ai listed multiple times (mixed case to also exercise the + // case-insensitive comparer) — must collapse to one set. + var result = JsBindingsPresets.ResolveAndUnion(new[] { "ai", "AI", "ai" }); + CollectionAssert.AreEqual( + new[] { "Microsoft.WindowsAppSDK.AI" }, + result.ToList(), + "Repeated presets must collapse — no double package IDs"); + } + + [TestMethod] + public void ResolveAndUnion_UnknownNames_AreSkippedSilently() + { + // Skip unknown but keep the known one. Validation is the CLI parser's job. + var result = JsBindingsPresets.ResolveAndUnion(new[] { "bogus", "ai", "" }); + CollectionAssert.AreEqual( + new[] { "Microsoft.WindowsAppSDK.AI" }, + result.ToList()); + } + + [TestMethod] + public void AliasFlagName_FormatsAsExpected() + { + // Single source of truth for the --js-bindings-{preset} naming scheme. + Assert.AreEqual("--js-bindings-ai", JsBindingsPresets.AliasFlagName("ai")); + Assert.AreEqual("--js-bindings-ai", JsBindingsPresets.AliasFlagName("AI"), + "Casing of input must not leak into the flag name"); + Assert.AreEqual("--js-bindings-future", JsBindingsPresets.AliasFlagName("Future"), + "Format must work for any preset name (regression guard for future presets)"); + } + + // Per-package winmd categorization — emit / ref-only / skip. + + [TestMethod] + public void ClassifyPackage_WinUI_IsSkipped() + { + // Pure XAML composables — drop entirely. + Assert.AreEqual(WinmdPackageCategory.Skip, + JsBindingsPresets.ClassifyPackage("Microsoft.WindowsAppSDK.WinUI")); + Assert.AreEqual(WinmdPackageCategory.Skip, + JsBindingsPresets.ClassifyPackage("microsoft.windowsappsdk.winui"), + "Package ID match must be case-insensitive (NuGet cache lowercases)."); + } + + [TestMethod] + public void ClassifyPackage_InteractiveExperiences_IsRefOnly() + { + // Ships Microsoft.UI.WindowId / Microsoft.Graphics.PointInt32 etc. + // — primitives referenced by other packages. + Assert.AreEqual(WinmdPackageCategory.RefOnly, + JsBindingsPresets.ClassifyPackage("Microsoft.WindowsAppSDK.InteractiveExperiences")); + Assert.AreEqual(WinmdPackageCategory.RefOnly, + JsBindingsPresets.ClassifyPackage("microsoft.windowsappsdk.interactiveexperiences")); + } + + [TestMethod] + public void ClassifyPackage_UnknownPackage_DefaultsToEmit() + { + Assert.AreEqual(WinmdPackageCategory.Emit, + JsBindingsPresets.ClassifyPackage("Microsoft.WindowsAppSDK.AI")); + Assert.AreEqual(WinmdPackageCategory.Emit, + JsBindingsPresets.ClassifyPackage("Microsoft.WindowsAppSDK.Foundation")); + Assert.AreEqual(WinmdPackageCategory.Emit, + JsBindingsPresets.ClassifyPackage("anything.else")); + Assert.AreEqual(WinmdPackageCategory.Emit, + JsBindingsPresets.ClassifyPackage(""), + "Empty/null package id (e.g. vendor winmds outside the cache) defaults to Emit."); + } + + [TestMethod] + public void ExtractPackageIdFromPath_FlatMetadataLayout_ReturnsPackageId() + { + // The Microsoft.WindowsAppSDK.AI layout: metadata files sit directly under metadata/. + var p = @"C:\Users\u\.nuget\packages\microsoft.windowsappsdk.ai\1.8.39\metadata\Microsoft.Windows.AI.winmd"; + Assert.AreEqual("microsoft.windowsappsdk.ai", JsBindingsPresets.ExtractPackageIdFromPath(p)); + } + + [TestMethod] + public void ExtractPackageIdFromPath_NestedSdkVersionLayout_ReturnsPackageId() + { + // The InteractiveExperiences layout: metadata files nested under metadata/10.0.18362.0/. + var p = @"C:\Users\u\.nuget\packages\microsoft.windowsappsdk.interactiveexperiences\1.8.251104001\metadata\10.0.18362.0\Microsoft.UI.winmd"; + Assert.AreEqual("microsoft.windowsappsdk.interactiveexperiences", + JsBindingsPresets.ExtractPackageIdFromPath(p)); + } + + [TestMethod] + public void ExtractPackageIdFromPath_NonNuGetPath_ReturnsNull() + { + // Vendor winmd outside the cache — no "packages" segment. + var p = @"C:\src\my-project\vendor\Custom.winmd"; + Assert.IsNull(JsBindingsPresets.ExtractPackageIdFromPath(p)); + } + + [TestMethod] + public void ExtractPackageIdFromPath_ForwardSlashPath_AlsoWorks() + { + // Defensive: we may get either separator on Windows. + var p = "C:/Users/u/.nuget/packages/microsoft.windowsappsdk.ai/1.8.39/metadata/Foo.winmd"; + Assert.AreEqual("microsoft.windowsappsdk.ai", JsBindingsPresets.ExtractPackageIdFromPath(p)); + } + + [TestMethod] + public void PartitionByPackageCategory_MixedSet_SplitsCorrectly() + { + var files = new[] + { + new FileInfo(@"C:\u\.nuget\packages\microsoft.windowsappsdk.ai\1.8.39\metadata\Microsoft.Windows.AI.winmd"), + new FileInfo(@"C:\u\.nuget\packages\microsoft.windowsappsdk.foundation\1.8.0\metadata\Microsoft.Windows.Storage.winmd"), + new FileInfo(@"C:\u\.nuget\packages\microsoft.windowsappsdk.winui\1.8.0\metadata\Microsoft.UI.Xaml.winmd"), + new FileInfo(@"C:\u\.nuget\packages\microsoft.windowsappsdk.interactiveexperiences\1.8.0\metadata\10.0.18362.0\Microsoft.UI.winmd"), + new FileInfo(@"C:\u\.nuget\packages\microsoft.windowsappsdk.interactiveexperiences\1.8.0\metadata\10.0.18362.0\Microsoft.Graphics.winmd"), + new FileInfo(@"C:\src\vendor\MyCo.Custom.winmd"), + }; + + var p = JsBindingsPresets.PartitionByPackageCategory(files); + + Assert.AreEqual(3, p.Emit.Count, + "AI + Foundation + vendor winmd should land in Emit"); + Assert.IsTrue(p.Emit.Any(f => f.FullName.EndsWith("Microsoft.Windows.AI.winmd", StringComparison.OrdinalIgnoreCase))); + Assert.IsTrue(p.Emit.Any(f => f.FullName.EndsWith("Microsoft.Windows.Storage.winmd", StringComparison.OrdinalIgnoreCase))); + Assert.IsTrue(p.Emit.Any(f => f.FullName.EndsWith("MyCo.Custom.winmd", StringComparison.OrdinalIgnoreCase)), + "Vendor winmds outside the NuGet cache must default to Emit."); + + Assert.AreEqual(2, p.RefOnly.Count, + "Both InteractiveExperiences winmds (Microsoft.UI + Microsoft.Graphics) go to RefOnly"); + + Assert.AreEqual(1, p.Skipped.Count, + "Microsoft.UI.Xaml.winmd from the WinUI package is dropped entirely"); + Assert.IsTrue(p.Skipped[0].FullName.EndsWith("Microsoft.UI.Xaml.winmd", StringComparison.OrdinalIgnoreCase)); + } + + [TestMethod] + public void PartitionByPackageCategory_EmptyInput_ReturnsAllEmpty() + { + var p = JsBindingsPresets.PartitionByPackageCategory(Array.Empty()); + Assert.AreEqual(0, p.Emit.Count); + Assert.AreEqual(0, p.RefOnly.Count); + Assert.AreEqual(0, p.Skipped.Count); + } + + // ------------------------------------------------------------------------- + // v2.3 — PackageCategoryOverrides + // ------------------------------------------------------------------------- + + [TestMethod] + public void ClassifyPackage_UserEmit_OverridesDefaultSkip() + { + // Default would Skip WinUI; user force-emits → must become Emit. + var ov = new JsBindingsPresets.PackageCategoryOverrides + { + Emit = new HashSet(StringComparer.OrdinalIgnoreCase) { "Microsoft.WindowsAppSDK.WinUI" }, + }; + Assert.AreEqual(WinmdPackageCategory.Emit, + JsBindingsPresets.ClassifyPackage("Microsoft.WindowsAppSDK.WinUI", ov)); + } + + [TestMethod] + public void ClassifyPackage_UserSkip_AppendedToDefault() + { + var ov = new JsBindingsPresets.PackageCategoryOverrides + { + Skip = new HashSet(StringComparer.OrdinalIgnoreCase) { "Some.New.XAML.Package" }, + }; + Assert.AreEqual(WinmdPackageCategory.Skip, + JsBindingsPresets.ClassifyPackage("Some.New.XAML.Package", ov)); + // Default skip list still honored. + Assert.AreEqual(WinmdPackageCategory.Skip, + JsBindingsPresets.ClassifyPackage("Microsoft.WindowsAppSDK.WinUI", ov)); + } + + [TestMethod] + public void ClassifyPackage_UserRefOnly_AppendedToDefault() + { + var ov = new JsBindingsPresets.PackageCategoryOverrides + { + RefOnly = new HashSet(StringComparer.OrdinalIgnoreCase) { "Vendor.PrimitiveTypes" }, + }; + Assert.AreEqual(WinmdPackageCategory.RefOnly, + JsBindingsPresets.ClassifyPackage("Vendor.PrimitiveTypes", ov)); + // InteractiveExperiences still ref-only by default. + Assert.AreEqual(WinmdPackageCategory.RefOnly, + JsBindingsPresets.ClassifyPackage("Microsoft.WindowsAppSDK.InteractiveExperiences", ov)); + } + + [TestMethod] + public void ClassifyPackage_UserEmit_BeatsBothUserSkipAndUserRefOnly() + { + // If users list the same package in both Skip and Emit, Emit wins + // (most permissive — they explicitly want bindings). + var ov = new JsBindingsPresets.PackageCategoryOverrides + { + Skip = new HashSet(StringComparer.OrdinalIgnoreCase) { "Foo" }, + RefOnly = new HashSet(StringComparer.OrdinalIgnoreCase) { "Foo" }, + Emit = new HashSet(StringComparer.OrdinalIgnoreCase) { "Foo" }, + }; + Assert.AreEqual(WinmdPackageCategory.Emit, + JsBindingsPresets.ClassifyPackage("Foo", ov)); + } + + [TestMethod] + public void PackageCategoryOverrides_From_NullConfig_ReturnsEmpty() + { + var ov = JsBindingsPresets.PackageCategoryOverrides.From(null); + Assert.IsNull(ov.Skip); + Assert.IsNull(ov.RefOnly); + Assert.IsNull(ov.Emit); + } + + [TestMethod] + public void PackageCategoryOverrides_From_PopulatedConfig_MapsAllThreeLists() + { + var cfg = new WinApp.Cli.Models.JsBindingsConfig + { + SkipPackages = { "S1" }, + RefOnlyPackages = { "R1", "R2" }, + EmitPackages = { "E1" }, + }; + var ov = JsBindingsPresets.PackageCategoryOverrides.From(cfg); + Assert.IsNotNull(ov.Skip); + Assert.IsTrue(ov.Skip!.Contains("S1")); + Assert.IsNotNull(ov.RefOnly); + Assert.AreEqual(2, ov.RefOnly!.Count); + Assert.IsNotNull(ov.Emit); + Assert.IsTrue(ov.Emit!.Contains("E1")); + } + + [TestMethod] + public void PartitionByPackageCategory_UserOverrides_RedirectPackage() + { + // WinUI default = skip; force-emit it via override → ends up in Emit. + var files = new[] + { + new FileInfo(@"C:\u\.nuget\packages\microsoft.windowsappsdk.ai\1.8.39\metadata\AI.winmd"), + new FileInfo(@"C:\u\.nuget\packages\microsoft.windowsappsdk.winui\1.8\metadata\Xaml.winmd"), + }; + var ov = new JsBindingsPresets.PackageCategoryOverrides + { + Emit = new HashSet(StringComparer.OrdinalIgnoreCase) { "Microsoft.WindowsAppSDK.WinUI" }, + }; + var p = JsBindingsPresets.PartitionByPackageCategory(files, ov); + Assert.AreEqual(2, p.Emit.Count, "Both AI and WinUI should now emit (user force-emit on WinUI)."); + Assert.AreEqual(0, p.Skipped.Count); + } + + // emit scope demotes out-of-scope emit-category packages + // to RefOnly so codegen still has metadata for cross-package type + // resolution. This is the core regression test for the live-discovery + // bug where scope was applied BEFORE discovery, dropping refs entirely. + [TestMethod] + public void PartitionByPackageCategory_EmitScope_OutOfScopeEmitPackages_DemotedToRefOnly() + { + var files = new[] + { + new FileInfo(@"C:\u\.nuget\packages\microsoft.windowsappsdk.ai\1.8.39\metadata\AI.winmd"), + // Core WindowsAppSDK is NOT in scope but its types are + // referenced by AI — must end up as RefOnly, not dropped. + new FileInfo(@"C:\u\.nuget\packages\microsoft.windowsappsdk\1.8.39\lib\Core.winmd"), + new FileInfo(@"C:\u\.nuget\packages\microsoft.web.webview2\1.0.0\runtimes\WebView2.winmd"), + }; + + var scope = new[] { "Microsoft.WindowsAppSDK.AI" }; + var p = JsBindingsPresets.PartitionByPackageCategory( + files, overrides: null, nugetCacheRoot: null, emitScope: scope); + + Assert.AreEqual(1, p.Emit.Count, "Only AI is in scope, so only AI emits."); + Assert.IsTrue(p.Emit[0].FullName.EndsWith("AI.winmd", StringComparison.OrdinalIgnoreCase)); + + Assert.AreEqual(2, p.RefOnly.Count, + "Out-of-scope emit-category packages (core SDK + WebView2) MUST be demoted to RefOnly, " + + "NOT dropped — codegen needs them for type resolution."); + Assert.IsTrue(p.RefOnly.Any(f => f.FullName.EndsWith("Core.winmd", StringComparison.OrdinalIgnoreCase))); + Assert.IsTrue(p.RefOnly.Any(f => f.FullName.EndsWith("WebView2.winmd", StringComparison.OrdinalIgnoreCase))); + } + + [TestMethod] + public void PartitionByPackageCategory_EmitScope_NullOrEmpty_NoFiltering() + { + // No emit scope = full default partitioning (no demotion happens). + var files = new[] + { + new FileInfo(@"C:\u\.nuget\packages\microsoft.windowsappsdk.ai\1.8.39\metadata\AI.winmd"), + new FileInfo(@"C:\u\.nuget\packages\microsoft.windowsappsdk\1.8.39\lib\Core.winmd"), + }; + + var pNull = JsBindingsPresets.PartitionByPackageCategory(files, emitScope: null); + Assert.AreEqual(2, pNull.Emit.Count, "Null scope = full emit."); + Assert.AreEqual(0, pNull.RefOnly.Count); + + var pEmpty = JsBindingsPresets.PartitionByPackageCategory(files, emitScope: Array.Empty()); + Assert.AreEqual(2, pEmpty.Emit.Count, "Empty scope = full emit (same as null)."); + } + + [TestMethod] + public void PartitionByPackageCategory_EmitScope_SkipCategoryWins() + { + // Skip-classified packages stay Skipped even when in scope — the + // user-override skip is stronger than scope inclusion. + var files = new[] + { + new FileInfo(@"C:\u\.nuget\packages\microsoft.windowsappsdk.ai\1.8.39\metadata\AI.winmd"), + // WinUI is in the default-skip set. + new FileInfo(@"C:\u\.nuget\packages\microsoft.windowsappsdk.winui\1.8\metadata\Xaml.winmd"), + }; + + var scope = new[] { "Microsoft.WindowsAppSDK.AI", "Microsoft.WindowsAppSDK.WinUI" }; + var p = JsBindingsPresets.PartitionByPackageCategory( + files, overrides: null, nugetCacheRoot: null, emitScope: scope); + + Assert.AreEqual(1, p.Emit.Count, "AI emits."); + Assert.AreEqual(1, p.Skipped.Count, "WinUI stays skipped even though in scope."); + } + + [TestMethod] + public void PartitionByPackageCategory_EmitScope_RefOnlyCategoryWins() + { + // RefOnly-classified packages stay RefOnly when in scope — the + // classification is stronger. + var files = new[] + { + new FileInfo(@"C:\u\.nuget\packages\microsoft.windowsappsdk.ai\1.8.39\metadata\AI.winmd"), + new FileInfo(@"C:\u\.nuget\packages\some.vendor.pkg\1.0\lib\Vendor.winmd"), + }; + var ov = new JsBindingsPresets.PackageCategoryOverrides + { + RefOnly = new HashSet(StringComparer.OrdinalIgnoreCase) { "Some.Vendor.Pkg" }, + }; + + var p = JsBindingsPresets.PartitionByPackageCategory( + files, ov, nugetCacheRoot: null, + emitScope: new[] { "Microsoft.WindowsAppSDK.AI", "Some.Vendor.Pkg" }); + + Assert.AreEqual(1, p.Emit.Count, "AI emits."); + Assert.AreEqual(1, p.RefOnly.Count, "Vendor stays RefOnly via classification override."); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/NpmWrapperVersionProviderTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/NpmWrapperVersionProviderTests.cs new file mode 100644 index 00000000..cad5e53f --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/NpmWrapperVersionProviderTests.cs @@ -0,0 +1,168 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.Services; + +namespace WinApp.Cli.Tests; + +// Tests for NpmWrapperVersionProvider. ProcessPath in `dotnet test` points +// at testhost.exe (outside any npm layout), so we exercise the failure path. +[TestClass] +public class NpmWrapperVersionProviderTests +{ + private DirectoryInfo _temp = null!; + + [TestInitialize] + public void Init() + { + _temp = new DirectoryInfo(Path.Combine(Path.GetTempPath(), $"NpmWrapperTests_{Guid.NewGuid():N}")); + _temp.Create(); + } + + [TestCleanup] + public void Cleanup() + { + try { _temp.Delete(recursive: true); } catch { /* ignore */ } + } + + [TestMethod] + public void DynWinrtVersion_OutsideNpmLayout_ThrowsInvalidOperationWithDIHint() + { + var provider = new NpmWrapperVersionProvider(); + + var ex = Assert.ThrowsExactly( + () => _ = provider.DynWinrtVersion); + StringAssert.Contains(ex.Message, "@microsoft/winappcli"); + StringAssert.Contains(ex.Message, "INpmWrapperVersionProvider", + "Error must point users at the DI override they need to register"); + } + + [TestMethod] + public void DynWinrtCodegenVersion_OutsideNpmLayout_ThrowsInvalidOperation() + { + var provider = new NpmWrapperVersionProvider(); + Assert.ThrowsExactly( + () => _ = provider.DynWinrtCodegenVersion); + } + + [TestMethod] + public void Versions_AreLazyAndShared() + { + // Lazy should cache and replay the same failure across both props. + var provider = new NpmWrapperVersionProvider(); + var first = Assert.ThrowsExactly( + () => _ = provider.DynWinrtVersion); + var second = Assert.ThrowsExactly( + () => _ = provider.DynWinrtCodegenVersion); + Assert.AreEqual(first.Message, second.Message, + "Lazy should cache and replay the same failure"); + } + + // ── Happy-path / structural tests against the LocateFrom seam ─────── + + [TestMethod] + public void LocateFrom_ValidWrapperLayout_ReturnsCodegenDependencyVersion() + { + // Simulates node_modules/@microsoft/winappcli/{package.json + bin//winapp.exe} + var pkgDir = Directory.CreateDirectory( + Path.Combine(_temp.FullName, "node_modules", "@microsoft", "winappcli")); + var binDir = Directory.CreateDirectory(Path.Combine(pkgDir.FullName, "bin", "win-arm64")); + File.WriteAllText(Path.Combine(pkgDir.FullName, "package.json"), """ + { + "name": "@microsoft/winappcli", + "version": "0.3.2", + "dependencies": { + "@microsoft/dynwinrt-codegen": "0.1.0-preview.1" + } + } + """); + + var version = NpmWrapperVersionProvider.LocateFrom(binDir.FullName); + + Assert.AreEqual("0.1.0-preview.1", version); + } + + [TestMethod] + public void LocateFrom_UnrelatedPackageJsonInParent_KeepsWalkingForWrapper() + { + // Common case: project package.json appears before the wrapper one. + var workspace = Directory.CreateDirectory(Path.Combine(_temp.FullName, "user-workspace")); + File.WriteAllText(Path.Combine(workspace.FullName, "package.json"), """ + { "name": "some-user-project", "version": "1.0.0" } + """); + var wrapperDir = Directory.CreateDirectory( + Path.Combine(workspace.FullName, "node_modules", "@microsoft", "winappcli")); + File.WriteAllText(Path.Combine(wrapperDir.FullName, "package.json"), """ + { + "name": "@microsoft/winappcli", + "version": "0.3.2", + "dependencies": { "@microsoft/dynwinrt-codegen": "9.9.9-from-wrapper" } + } + """); + var binDir = Directory.CreateDirectory(Path.Combine(wrapperDir.FullName, "bin", "win-x64")); + + var version = NpmWrapperVersionProvider.LocateFrom(binDir.FullName); + + Assert.AreEqual("9.9.9-from-wrapper", version, + "Walker must skip unrelated package.json files and only accept the wrapper one."); + } + + [TestMethod] + public void LocateFrom_WrapperPackageJsonWithoutDependencies_Throws() + { + var pkgDir = Directory.CreateDirectory( + Path.Combine(_temp.FullName, "node_modules", "@microsoft", "winappcli")); + File.WriteAllText(Path.Combine(pkgDir.FullName, "package.json"), """ + { "name": "@microsoft/winappcli", "version": "0.3.2" } + """); + var binDir = Directory.CreateDirectory(Path.Combine(pkgDir.FullName, "bin")); + + var ex = Assert.ThrowsExactly( + () => NpmWrapperVersionProvider.LocateFrom(binDir.FullName)); + StringAssert.Contains(ex.Message, "dependencies"); + } + + [TestMethod] + public void LocateFrom_WrapperPackageJsonMissingCodegenDep_Throws() + { + var pkgDir = Directory.CreateDirectory( + Path.Combine(_temp.FullName, "node_modules", "@microsoft", "winappcli")); + File.WriteAllText(Path.Combine(pkgDir.FullName, "package.json"), """ + { + "name": "@microsoft/winappcli", + "version": "0.3.2", + "dependencies": { "some-other-pkg": "1.0.0" } + } + """); + var binDir = Directory.CreateDirectory(Path.Combine(pkgDir.FullName, "bin")); + + var ex = Assert.ThrowsExactly( + () => NpmWrapperVersionProvider.LocateFrom(binDir.FullName)); + StringAssert.Contains(ex.Message, "@microsoft/dynwinrt-codegen"); + } + + [TestMethod] + public void LocateFrom_MalformedPackageJson_Throws() + { + var pkgDir = Directory.CreateDirectory( + Path.Combine(_temp.FullName, "node_modules", "@microsoft", "winappcli")); + File.WriteAllText(Path.Combine(pkgDir.FullName, "package.json"), "{ not valid json"); + var binDir = Directory.CreateDirectory(Path.Combine(pkgDir.FullName, "bin")); + + var ex = Assert.ThrowsExactly( + () => NpmWrapperVersionProvider.LocateFrom(binDir.FullName)); + StringAssert.Contains(ex.Message, "Failed to parse"); + } + + [TestMethod] + public void LocateFrom_NoWrapperAnywhere_ThrowsWithDIHint() + { + // No package.json at any ancestor. + var bare = Directory.CreateDirectory(Path.Combine(_temp.FullName, "bare")); + + var ex = Assert.ThrowsExactly( + () => NpmWrapperVersionProvider.LocateFrom(bare.FullName)); + StringAssert.Contains(ex.Message, "@microsoft/winappcli"); + StringAssert.Contains(ex.Message, "INpmWrapperVersionProvider"); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/PackageManagerDetectorTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/PackageManagerDetectorTests.cs new file mode 100644 index 00000000..025a2a93 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/PackageManagerDetectorTests.cs @@ -0,0 +1,149 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.Services; + +namespace WinApp.Cli.Tests; + +// Unit tests for PackageManagerDetector. Covers each detection +// signal (Corepack packageManager field, lockfile sniffing, fallback) +// and the priority ordering between them. +[TestClass] +public class PackageManagerDetectorTests +{ + private DirectoryInfo _tempDir = null!; + private PackageManagerDetector _detector = null!; + + [TestInitialize] + public void Setup() + { + _tempDir = new DirectoryInfo( + Path.Combine(Path.GetTempPath(), $"PMDetectorTests_{Guid.NewGuid():N}")); + _tempDir.Create(); + _detector = new PackageManagerDetector(); + } + + [TestCleanup] + public void Teardown() + { + try { _tempDir.Delete(true); } catch { /* ignore */ } + } + + [TestMethod] + public void Detect_NoSignals_ReturnsNpmDefault() + { + var result = _detector.Detect(_tempDir); + Assert.AreEqual("npm", result.Name); + Assert.AreEqual("npm install", result.InstallCommand); + } + + [TestMethod] + public void Detect_PnpmLockfile_ReturnsPnpm() + { + File.WriteAllText(Path.Combine(_tempDir.FullName, "pnpm-lock.yaml"), "lockfileVersion: 9\n"); + var result = _detector.Detect(_tempDir); + Assert.AreEqual("pnpm", result.Name); + Assert.AreEqual("pnpm install", result.InstallCommand); + } + + [TestMethod] + public void Detect_YarnLockfile_ReturnsYarn() + { + File.WriteAllText(Path.Combine(_tempDir.FullName, "yarn.lock"), "# yarn lockfile v1\n"); + var result = _detector.Detect(_tempDir); + Assert.AreEqual("yarn", result.Name); + Assert.AreEqual("yarn install", result.InstallCommand); + } + + [TestMethod] + public void Detect_BunLockfile_ReturnsBun() + { + // Bun ships either `bun.lockb` (binary, older) or `bun.lock` (text, newer). + File.WriteAllBytes(Path.Combine(_tempDir.FullName, "bun.lockb"), new byte[] { 0x00, 0x01 }); + var result = _detector.Detect(_tempDir); + Assert.AreEqual("bun", result.Name); + Assert.AreEqual("bun install", result.InstallCommand); + } + + [TestMethod] + public void Detect_BunTextLockfile_ReturnsBun() + { + File.WriteAllText(Path.Combine(_tempDir.FullName, "bun.lock"), "{}\n"); + var result = _detector.Detect(_tempDir); + Assert.AreEqual("bun", result.Name); + } + + [TestMethod] + public void Detect_PackageLockJson_ReturnsNpm() + { + File.WriteAllText(Path.Combine(_tempDir.FullName, "package-lock.json"), "{}\n"); + var result = _detector.Detect(_tempDir); + Assert.AreEqual("npm", result.Name); + Assert.AreEqual("npm install", result.InstallCommand); + } + + [TestMethod] + public void Detect_PnpmLockBeatsPackageLock_PnpmWins() + { + // When both lockfiles exist (e.g. user migrated), pnpm-lock.yaml is + // the stronger signal because package-lock.json can be auto-created + // by other tools. + File.WriteAllText(Path.Combine(_tempDir.FullName, "pnpm-lock.yaml"), "lockfileVersion: 9\n"); + File.WriteAllText(Path.Combine(_tempDir.FullName, "package-lock.json"), "{}\n"); + var result = _detector.Detect(_tempDir); + Assert.AreEqual("pnpm", result.Name); + } + + [TestMethod] + public void Detect_CorepackPackageManagerField_BeatsLockfile() + { + // Even with an npm lockfile, an explicit `packageManager: pnpm@…` + // declaration in package.json is the authoritative signal. + File.WriteAllText(Path.Combine(_tempDir.FullName, "package-lock.json"), "{}\n"); + File.WriteAllText( + Path.Combine(_tempDir.FullName, "package.json"), + "{ \"packageManager\": \"pnpm@9.5.0\" }"); + var result = _detector.Detect(_tempDir); + Assert.AreEqual("pnpm", result.Name); + Assert.AreEqual("pnpm install", result.InstallCommand); + } + + [TestMethod] + public void Detect_CorepackWithShaSuffix_StillParses() + { + // Corepack format allows `@+sha512.`. + File.WriteAllText( + Path.Combine(_tempDir.FullName, "package.json"), + "{ \"packageManager\": \"yarn@4.1.1+sha224.abcdef\" }"); + var result = _detector.Detect(_tempDir); + Assert.AreEqual("yarn", result.Name); + } + + [TestMethod] + public void Detect_CorepackUnknownPM_FallsThroughToLockfile() + { + // Future PMs we haven't heard of should not crash detection; we fall + // through to the lockfile sniffing layer instead. + File.WriteAllText(Path.Combine(_tempDir.FullName, "yarn.lock"), "# yarn lockfile v1\n"); + File.WriteAllText( + Path.Combine(_tempDir.FullName, "package.json"), + "{ \"packageManager\": \"futurepm@1.0.0\" }"); + var result = _detector.Detect(_tempDir); + Assert.AreEqual("yarn", result.Name); + } + + [TestMethod] + public void Detect_MalformedPackageJson_FallsBack() + { + // Detection must not crash if package.json is invalid JSON. + File.WriteAllText(Path.Combine(_tempDir.FullName, "package.json"), "not valid json{"); + var result = _detector.Detect(_tempDir); + Assert.AreEqual("npm", result.Name); + } + + [TestMethod] + public void Detect_NullArg_Throws() + { + Assert.ThrowsExactly(() => _detector.Detect(null!)); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/TestDoubles/FakeDynWinrtCodegenService.cs b/src/winapp-CLI/WinApp.Cli.Tests/TestDoubles/FakeDynWinrtCodegenService.cs new file mode 100644 index 00000000..7a46e9c0 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/TestDoubles/FakeDynWinrtCodegenService.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.ConsoleTasks; +using WinApp.Cli.Models; +using WinApp.Cli.Services; + +namespace WinApp.Cli.Tests.TestDoubles; + +// In-memory IDynWinrtCodegenService — orchestration tests use it instead +// of spawning the real codegen binary. +internal sealed class FakeDynWinrtCodegenService : IDynWinrtCodegenService +{ + public List Calls { get; } = new(); + + // When non-null, RunAsync throws after recording the call. + public Exception? FailWith { get; set; } + + // Stub files written into the output dir on success. + public IReadOnlyDictionary StubFilesPerCall { get; set; } + = new Dictionary { ["index.js"] = "// fake codegen output" }; + + public Task RunAsync( + JsBindingsConfig config, + IReadOnlyList winmds, + FileInfo? windowsSdkWinmd, + DirectoryInfo workspaceDir, + DirectoryInfo winappDir, + TaskContext taskContext, + IReadOnlyList? userAdditionalWinmds = null, + IReadOnlyList? userAdditionalRefs = null, + CancellationToken cancellationToken = default) + { + Calls.Add(new CallRecord + { + Config = config, + EmitWinmds = winmds.Select(f => f.FullName).ToArray(), + UserAdditionalWinmds = (userAdditionalWinmds ?? Array.Empty()).Select(f => f.FullName).ToArray(), + UserAdditionalRefs = (userAdditionalRefs ?? Array.Empty()).Select(f => f.FullName).ToArray(), + WorkspaceDir = workspaceDir.FullName, + WinappDir = winappDir.FullName, + }); + + if (FailWith is not null) + { + throw FailWith; + } + + // Mirror the real success contract: output dir + stub files + marker. + var outputDir = DynWinrtCodegenService.ResolveOutputDir(workspaceDir, config.Output); + outputDir.Create(); + foreach (var (relPath, content) in StubFilesPerCall) + { + var fullPath = Path.Combine(outputDir.FullName, relPath); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + File.WriteAllText(fullPath, content); + } + File.WriteAllText( + Path.Combine(outputDir.FullName, DynWinrtCodegenService.ManagedMarkerFileName), + "# fake managed marker\n"); + return Task.FromResult(outputDir); + } + + public sealed class CallRecord + { + public JsBindingsConfig Config { get; set; } = null!; + public string[] EmitWinmds { get; set; } = Array.Empty(); + public string[] UserAdditionalWinmds { get; set; } = Array.Empty(); + public string[] UserAdditionalRefs { get; set; } = Array.Empty(); + public string WorkspaceDir { get; set; } = string.Empty; + public string WinappDir { get; set; } = string.Empty; + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/TestDoubles/FakeJsBindingsWorkspaceService.cs b/src/winapp-CLI/WinApp.Cli.Tests/TestDoubles/FakeJsBindingsWorkspaceService.cs new file mode 100644 index 00000000..2ff9fce6 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/TestDoubles/FakeJsBindingsWorkspaceService.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.ConsoleTasks; +using WinApp.Cli.Services; + +namespace WinApp.Cli.Tests.TestDoubles; + +// Recording fake for the init/restore JS-bindings exec path. Lets tests +// drive WorkspaceSetupService → JsBindingsWorkspaceService.RunAsync without +// spawning real codegen or seeding NuGet caches. +internal sealed class FakeJsBindingsWorkspaceService : IJsBindingsWorkspaceService +{ + public List Calls { get; } = new(); + + // When set, RunAsync returns this result instead of a default success. + public JsBindingsOrchestrationResult? Result { get; set; } + + public Task RunAsync( + JsBindingsOrchestrationContext context, + TaskContext taskContext, + CancellationToken cancellationToken = default) + { + Calls.Add(context); + return Task.FromResult(Result ?? new JsBindingsOrchestrationResult + { + ExitCode = 0, + Message = "fake codegen success", + OutputDir = context.WorkspaceDir, + }); + } + + public bool EnsureRuntimeDependencyCalled { get; private set; } + public void EnsureRuntimeDependencyAndPrintHint(DirectoryInfo workspaceDirectory) + { + EnsureRuntimeDependencyCalled = true; + } + + // AddAsync isn't exercised by the init/restore wiring tests (those go + // through RunAsync). Stub returns success so the interface is satisfied. + public Task AddAsync(AddJsBindingsOptions options, CancellationToken cancellationToken = default) + => Task.FromResult(0); + + public Task GenerateAsync(GenerateJsBindingsOptions options, CancellationToken cancellationToken = default) + => Task.FromResult(0); +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UserPackageJsonServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/UserPackageJsonServiceTests.cs new file mode 100644 index 00000000..b670eaa0 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/UserPackageJsonServiceTests.cs @@ -0,0 +1,194 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Text.Json; +using WinApp.Cli.Services; + +namespace WinApp.Cli.Tests; + +// Unit tests for UserPackageJsonService. Covers each +// RuntimeDependencyOutcome branch plus formatting/ordering +// preservation guarantees. +[TestClass] +public class UserPackageJsonServiceTests +{ + private DirectoryInfo _tempDir = null!; + private UserPackageJsonService _service = null!; + + [TestInitialize] + public void Setup() + { + _tempDir = new DirectoryInfo( + Path.Combine(Path.GetTempPath(), $"UserPkgJsonTests_{Guid.NewGuid():N}")); + _tempDir.Create(); + _service = new UserPackageJsonService(); + } + + [TestCleanup] + public void Teardown() + { + try { _tempDir.Delete(true); } catch { /* ignore */ } + } + + private string PackageJsonPath => Path.Combine(_tempDir.FullName, "package.json"); + + [TestMethod] + public void EnsureRuntimeDependency_NoPackageJson_ReturnsNoPackageJson() + { + var outcome = _service.EnsureRuntimeDependency( + _tempDir, "@microsoft/dynwinrt", "1.0.0"); + Assert.AreEqual(RuntimeDependencyOutcome.NoPackageJson, outcome); + Assert.IsFalse(File.Exists(PackageJsonPath), + "We must not synthesize a package.json on the user's behalf"); + } + + [TestMethod] + public void EnsureRuntimeDependency_NoDependenciesObject_AddsAndReturnsAdded() + { + File.WriteAllText(PackageJsonPath, + "{\n \"name\": \"my-app\",\n \"version\": \"1.0.0\"\n}\n"); + + var outcome = _service.EnsureRuntimeDependency( + _tempDir, "@microsoft/dynwinrt", "1.0.0"); + + Assert.AreEqual(RuntimeDependencyOutcome.Added, outcome); + var content = File.ReadAllText(PackageJsonPath); + using var doc = JsonDocument.Parse(content); + var root = doc.RootElement; + Assert.AreEqual("my-app", root.GetProperty("name").GetString()); + Assert.AreEqual("1.0.0", + root.GetProperty("dependencies").GetProperty("@microsoft/dynwinrt").GetString()); + } + + [TestMethod] + public void EnsureRuntimeDependency_DependenciesExistsButMissingPackage_AddsAndReturnsAdded() + { + File.WriteAllText(PackageJsonPath, + "{\n \"name\": \"my-app\",\n \"dependencies\": {\n \"react\": \"19.0.0\"\n }\n}\n"); + + var outcome = _service.EnsureRuntimeDependency( + _tempDir, "@microsoft/dynwinrt", "1.0.0"); + + Assert.AreEqual(RuntimeDependencyOutcome.Added, outcome); + using var doc = JsonDocument.Parse(File.ReadAllText(PackageJsonPath)); + var deps = doc.RootElement.GetProperty("dependencies"); + Assert.AreEqual("19.0.0", deps.GetProperty("react").GetString(), + "Pre-existing deps must survive untouched"); + Assert.AreEqual("1.0.0", deps.GetProperty("@microsoft/dynwinrt").GetString()); + } + + [TestMethod] + public void EnsureRuntimeDependency_AlreadyInDependencies_NoOpReturnsAlreadyPresent() + { + File.WriteAllText(PackageJsonPath, + "{\n \"dependencies\": {\n \"@microsoft/dynwinrt\": \"0.5.0\"\n }\n}\n"); + var beforeMtime = File.GetLastWriteTimeUtc(PackageJsonPath); + + // Sleep to ensure mtime granularity reveals any unintended write. + Thread.Sleep(50); + + var outcome = _service.EnsureRuntimeDependency( + _tempDir, "@microsoft/dynwinrt", "1.0.0"); + + Assert.AreEqual(RuntimeDependencyOutcome.AlreadyPresent, outcome); + // We must not overwrite the user's pinned version. + using var doc = JsonDocument.Parse(File.ReadAllText(PackageJsonPath)); + Assert.AreEqual("0.5.0", + doc.RootElement.GetProperty("dependencies").GetProperty("@microsoft/dynwinrt").GetString()); + Assert.AreEqual(beforeMtime, File.GetLastWriteTimeUtc(PackageJsonPath), + "AlreadyPresent must not rewrite the file"); + } + + [TestMethod] + public void EnsureRuntimeDependency_OnlyInDevDependencies_ReturnsPresentInDev() + { + File.WriteAllText(PackageJsonPath, + "{\n \"devDependencies\": {\n \"@microsoft/dynwinrt\": \"0.5.0\"\n }\n}\n"); + var beforeMtime = File.GetLastWriteTimeUtc(PackageJsonPath); + Thread.Sleep(50); + + var outcome = _service.EnsureRuntimeDependency( + _tempDir, "@microsoft/dynwinrt", "1.0.0"); + + Assert.AreEqual(RuntimeDependencyOutcome.PresentInDevDependencies, outcome); + Assert.AreEqual(beforeMtime, File.GetLastWriteTimeUtc(PackageJsonPath), + "PresentInDevDependencies must not auto-promote (don't surprise the user)"); + } + + [TestMethod] + public void EnsureRuntimeDependency_PreservesUnrelatedKeysAndOrder() + { + File.WriteAllText(PackageJsonPath, + "{\n" + + " \"name\": \"my-app\",\n" + + " \"version\": \"2.3.4\",\n" + + " \"scripts\": { \"start\": \"node .\" },\n" + + " \"author\": \"alice\"\n" + + "}\n"); + + _service.EnsureRuntimeDependency(_tempDir, "@microsoft/dynwinrt", "1.0.0"); + + var content = File.ReadAllText(PackageJsonPath); + using var doc = JsonDocument.Parse(content); + var root = doc.RootElement; + + // Walk properties in their actual order. + var keysInOrder = root.EnumerateObject().Select(p => p.Name).ToList(); + // dependencies should appear right after version (matching the + // conventional layout); other keys should keep their relative order. + var versionIndex = keysInOrder.IndexOf("version"); + var depsIndex = keysInOrder.IndexOf("dependencies"); + Assert.IsTrue(versionIndex >= 0 && depsIndex == versionIndex + 1, + $"Expected dependencies right after version; got: [{string.Join(", ", keysInOrder)}]"); + + // Author still present (not lost during rebuild). + Assert.AreEqual("alice", root.GetProperty("author").GetString()); + } + + [TestMethod] + public void EnsureRuntimeDependency_PreservesTrailingNewline() + { + File.WriteAllText(PackageJsonPath, "{\n \"name\": \"my-app\"\n}\n"); + _service.EnsureRuntimeDependency(_tempDir, "@microsoft/dynwinrt", "1.0.0"); + var content = File.ReadAllText(PackageJsonPath); + Assert.IsTrue(content.EndsWith('\n'), + "POSIX text file convention: trailing newline must be preserved"); + } + + [TestMethod] + public void EnsureRuntimeDependency_NoTrailingNewline_DoesNotAddOne() + { + File.WriteAllText(PackageJsonPath, "{\"name\":\"my-app\"}"); + _service.EnsureRuntimeDependency(_tempDir, "@microsoft/dynwinrt", "1.0.0"); + var content = File.ReadAllText(PackageJsonPath); + Assert.IsFalse(content.EndsWith('\n'), + "Should preserve original trailing-newline state (none → none)"); + } + + [TestMethod] + public void EnsureRuntimeDependency_MalformedJson_Throws() + { + File.WriteAllText(PackageJsonPath, "not valid json{"); + Assert.ThrowsExactly(() => + _service.EnsureRuntimeDependency(_tempDir, "@microsoft/dynwinrt", "1.0.0")); + } + + [TestMethod] + public void EnsureRuntimeDependency_RootIsNotObject_Throws() + { + File.WriteAllText(PackageJsonPath, "[1, 2, 3]"); + Assert.ThrowsExactly(() => + _service.EnsureRuntimeDependency(_tempDir, "@microsoft/dynwinrt", "1.0.0")); + } + + [TestMethod] + public void EnsureRuntimeDependency_NullOrEmptyArgs_Throws() + { + Assert.ThrowsExactly(() => + _service.EnsureRuntimeDependency(null!, "x", "1.0.0")); + Assert.ThrowsExactly(() => + _service.EnsureRuntimeDependency(_tempDir, "", "1.0.0")); + Assert.ThrowsExactly(() => + _service.EnsureRuntimeDependency(_tempDir, "x", "")); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/WinmdsLockfileServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/WinmdsLockfileServiceTests.cs new file mode 100644 index 00000000..479458f5 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/WinmdsLockfileServiceTests.cs @@ -0,0 +1,287 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging.Abstractions; +using WinApp.Cli.Models; +using WinApp.Cli.Services; + +namespace WinApp.Cli.Tests; + +[TestClass] +public class WinmdsLockfileServiceTests +{ + public TestContext TestContext { get; set; } = null!; + + private DirectoryInfo _temp = null!; + private WinmdsLockfileService _svc = null!; + + [TestInitialize] + public void Init() + { + _temp = new DirectoryInfo(Path.Combine(Path.GetTempPath(), $"WinmdsLockfileTests_{Guid.NewGuid():N}")); + _temp.Create(); + _svc = new WinmdsLockfileService(NullLogger.Instance); + } + + [TestCleanup] + public void Cleanup() + { + try { _temp.Delete(recursive: true); } catch { /* ignore */ } + } + + [TestMethod] + public void GetLockfilePath_LandsUnderWinappDir() + { + var path = _svc.GetLockfilePath(_temp); + Assert.AreEqual(Path.Combine(_temp.FullName, "winmds.lock.json"), path.FullName); + } + + [TestMethod] + public async Task WriteAsync_ProducesIndentedSchemaVersionedJson() + { + var winapp = _temp.CreateSubdirectory("winapp"); + // ExtractPackageIdFromPath requires the literal "packages" segment + // (the NuGet cache convention) — keep test fixtures aligned with that. + var cache = _temp.CreateSubdirectory("packages"); + var winmd = new FileInfo(Path.Combine( + cache.FullName, "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd")); + winmd.Directory!.Create(); + await File.WriteAllTextAsync(winmd.FullName, ""); + + var usedVersions = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Microsoft.WindowsAppSDK.AI"] = "1.8.39", + }; + await _svc.WriteAsync(winapp, usedVersions, new[] { winmd }, cache, default); + + var path = _svc.GetLockfilePath(winapp); + Assert.IsTrue(path.Exists, "Lockfile must be written under .winapp/."); + var json = await File.ReadAllTextAsync(path.FullName); + StringAssert.Contains(json, "\"schema\": 2"); + StringAssert.Contains(json, "\"generated_at\""); + StringAssert.Contains(json, "Microsoft.WindowsAppSDK.AI"); + StringAssert.Contains(json, "\"category\": \"emit\""); + Assert.IsTrue(json.Contains('\n'), "Output must be indented (multiple lines)."); + var bytes = await File.ReadAllBytesAsync(path.FullName); + Assert.IsFalse( + bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF, + "Lockfile must be UTF-8 without BOM (so diff tools and external readers don't choke)."); + } + + [TestMethod] + public async Task RoundTrip_PreservesPackageVersionsAndCategories() + { + var winapp = _temp.CreateSubdirectory("winapp"); + var cache = _temp.CreateSubdirectory("packages"); + + // Build a realistic mix: emit + ref-only + skip + a package with zero winmds. + var aiWinmd = MakeFile(cache, "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); + var ieWinmd = MakeFile(cache, "microsoft.windowsappsdk.interactiveexperiences", "1.8.0", "metadata", "10.0.18362.0", "Microsoft.UI.winmd"); + var winuiWinmd = MakeFile(cache, "microsoft.windowsappsdk.winui", "1.8.0", "metadata", "Microsoft.UI.Xaml.winmd"); + + var usedVersions = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Microsoft.WindowsAppSDK.AI"] = "1.8.39", + ["Microsoft.WindowsAppSDK.InteractiveExperiences"] = "1.8.0", + ["Microsoft.WindowsAppSDK.WinUI"] = "1.8.0", + ["Microsoft.WindowsAppSDK"] = "1.8.0", // umbrella, no winmd + }; + await _svc.WriteAsync(winapp, usedVersions, new[] { aiWinmd, ieWinmd, winuiWinmd }, cache, default); + + var lockfile = await _svc.TryReadAsync(winapp, default); + Assert.IsNotNull(lockfile); + Assert.AreEqual(2, lockfile.Schema); + Assert.AreEqual(4, lockfile.Packages.Count); + + // Packages are sorted alphabetically (case-insensitive) by name. + var ai = lockfile.Packages.Single(p => p.Name == "Microsoft.WindowsAppSDK.AI"); + Assert.AreEqual("1.8.39", ai.Version); + Assert.AreEqual("emit", ai.Category); + Assert.AreEqual(1, ai.Winmds.Count); + Assert.IsTrue(ai.Winmds[0].EndsWith("Microsoft.Windows.AI.winmd", StringComparison.OrdinalIgnoreCase)); + + var ie = lockfile.Packages.Single(p => p.Name == "Microsoft.WindowsAppSDK.InteractiveExperiences"); + Assert.AreEqual("refOnly", ie.Category); + Assert.AreEqual(1, ie.Winmds.Count); + + var winui = lockfile.Packages.Single(p => p.Name == "Microsoft.WindowsAppSDK.WinUI"); + Assert.AreEqual("skip", winui.Category); + Assert.AreEqual(1, winui.Winmds.Count); + + var umbrella = lockfile.Packages.Single(p => p.Name == "Microsoft.WindowsAppSDK"); + Assert.AreEqual("emit", umbrella.Category); + Assert.AreEqual(0, umbrella.Winmds.Count, "Umbrella package has no winmd files; lockfile records it for completeness."); + } + + [TestMethod] + public async Task TryReadAsync_MissingFile_ReturnsNull() + { + var result = await _svc.TryReadAsync(_temp, default); + Assert.IsNull(result); + } + + [TestMethod] + public async Task TryReadAsync_CorruptedJson_ReturnsNull() + { + var path = _svc.GetLockfilePath(_temp); + await File.WriteAllTextAsync(path.FullName, "{this is not json"); + + var result = await _svc.TryReadAsync(_temp, default); + Assert.IsNull(result, "Corrupted lockfile must trigger fallback (return null) rather than throw."); + } + + [TestMethod] + public async Task TryReadAsync_UnknownSchemaVersion_ReturnsNull() + { + // A future schema bump must not crash older clients. + var path = _svc.GetLockfilePath(_temp); + await File.WriteAllTextAsync(path.FullName, "{\"schema\": 999, \"packages\": []}"); + + var result = await _svc.TryReadAsync(_temp, default); + Assert.IsNull(result, "Unknown schema version must be treated as missing."); + } + + [TestMethod] + public void BuildLockfile_VendorWinmdsOutsideCache_AreDropped() + { + // Lockfile is a record of "what restore put on disk", not user-supplied refs. + var cache = _temp.CreateSubdirectory("packages"); + var vendorPath = Path.Combine(_temp.FullName, "vendor", "MyCompany.Custom.winmd"); + Directory.CreateDirectory(Path.GetDirectoryName(vendorPath)!); + File.WriteAllText(vendorPath, ""); + + var lockfile = WinmdsLockfileService.BuildLockfile( + new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Microsoft.WindowsAppSDK.AI"] = "1.8.39" }, + new[] { new FileInfo(vendorPath) }, + cache); + + Assert.AreEqual(1, lockfile.Packages.Count); + Assert.AreEqual(0, lockfile.Packages[0].Winmds.Count, + "Vendor winmd outside the NuGet cache must not get attached to any package."); + } + + [TestMethod] + public void BuildLockfile_PartitionFromLockfile_AppliesScopeAsEmitFilter_DemotesUnscopedToRefOnly() + { + // scope narrows EMIT output, not codegen visibility. + // Unscoped packages (whose default category is Emit) must end up + // as RefOnly so cross-package type resolution still works. + var cache = _temp.CreateSubdirectory("packages"); + var aiWinmd = MakeFile(cache, "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); + var fdnWinmd = MakeFile(cache, "microsoft.windowsappsdk.foundation", "1.8.0", "metadata", "Microsoft.Windows.Foundation.winmd"); + + var lockfile = WinmdsLockfileService.BuildLockfile( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Microsoft.WindowsAppSDK.AI"] = "1.8.39", + ["Microsoft.WindowsAppSDK.Foundation"] = "1.8.0", + }, + new[] { aiWinmd, fdnWinmd }, + cache); + + var (emit, refOnly, skipped) = JsBindingsWorkspaceService.PartitionFromLockfile( + lockfile, new[] { "Microsoft.WindowsAppSDK.AI" }); + + Assert.AreEqual(1, emit.Count, "Only the scoped AI package emits."); + Assert.IsTrue(emit[0].FullName.EndsWith("Microsoft.Windows.AI.winmd", StringComparison.OrdinalIgnoreCase)); + Assert.AreEqual(1, refOnly.Count, + "Unscoped Foundation package MUST be preserved as RefOnly (it provides types AI references). " + + "An earlier implementation dropped the package entirely → broken codegen."); + Assert.IsTrue(refOnly[0].FullName.EndsWith("Microsoft.Windows.Foundation.winmd", StringComparison.OrdinalIgnoreCase)); + Assert.AreEqual(0, skipped); + } + + [TestMethod] + public void PartitionFromLockfile_NullScope_ReturnsAllPackages() + { + var cache = _temp.CreateSubdirectory("packages"); + var aiWinmd = MakeFile(cache, "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); + var winuiWinmd = MakeFile(cache, "microsoft.windowsappsdk.winui", "1.8.0", "metadata", "Microsoft.UI.Xaml.winmd"); + var ieWinmd = MakeFile(cache, "microsoft.windowsappsdk.interactiveexperiences", "1.8.0", "metadata", "Microsoft.UI.winmd"); + + var lockfile = WinmdsLockfileService.BuildLockfile( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Microsoft.WindowsAppSDK.AI"] = "1.8.39", + ["Microsoft.WindowsAppSDK.WinUI"] = "1.8.0", + ["Microsoft.WindowsAppSDK.InteractiveExperiences"] = "1.8.0", + }, + new[] { aiWinmd, winuiWinmd, ieWinmd }, + cache); + + var (emit, refOnly, skipped) = JsBindingsWorkspaceService.PartitionFromLockfile(lockfile, null); + + Assert.AreEqual(1, emit.Count); + Assert.AreEqual(1, refOnly.Count); + Assert.AreEqual(1, skipped, "WinUI package contributes 1 to the skipped count."); + } + + // ------------------------------------------------------------------------- + // v2.3 — yaml hash, atomic write, schema-bump back-compat + // ------------------------------------------------------------------------- + + [TestMethod] + public async Task WriteAsync_StoresYamlPackagesHash() + { + var winapp = _temp.CreateSubdirectory("winapp"); + var cache = _temp.CreateSubdirectory("packages"); + var winmd = MakeFile(cache, "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); + + await _svc.WriteAsync( + winapp, + new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Microsoft.WindowsAppSDK.AI"] = "1.8.39" }, + new[] { winmd }, + cache, + yamlPackagesHash: "abc123deadbeef", + cancellationToken: default); + + var lockfile = await _svc.TryReadAsync(winapp, default); + Assert.IsNotNull(lockfile); + Assert.AreEqual("abc123deadbeef", lockfile.YamlPackagesHash); + } + + [TestMethod] + public async Task TryReadAsync_Schema1Lockfile_ReturnsNull() + { + // Existing pre-v2.3 lockfiles use schema=1. Readers must treat them + // as missing so the slow path (re-discovery) rebuilds the lockfile. + var path = _svc.GetLockfilePath(_temp); + await File.WriteAllTextAsync(path.FullName, "{\"schema\": 1, \"packages\": []}"); + + var result = await _svc.TryReadAsync(_temp, default); + Assert.IsNull(result, "Schema 1 lockfiles must be ignored after the v2.3 schema bump."); + } + + [TestMethod] + public async Task WriteAsync_UsesAtomicTempThenRename() + { + // No reliable way to observe the tmp file mid-write in a unit test; + // verify post-conditions: final lockfile exists, no .tmp files left + // behind on a successful write. + var winapp = _temp.CreateSubdirectory("winapp"); + var cache = _temp.CreateSubdirectory("packages"); + var winmd = MakeFile(cache, "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "AI.winmd"); + + await _svc.WriteAsync( + winapp, + new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Microsoft.WindowsAppSDK.AI"] = "1.8.39" }, + new[] { winmd }, + cache, + yamlPackagesHash: "h", + cancellationToken: default); + + var entries = winapp.EnumerateFiles().Select(f => f.Name).ToList(); + CollectionAssert.Contains(entries, "winmds.lock.json", "Final lockfile must exist."); + Assert.IsFalse( + entries.Any(n => n.StartsWith("winmds.lock.json.tmp", StringComparison.Ordinal)), + $"No tmp staging file should remain after a successful write. Found: {string.Join(", ", entries)}"); + } + + private static FileInfo MakeFile(DirectoryInfo cache, params string[] segments) + { + var path = Path.Combine(new[] { cache.FullName }.Concat(segments).ToArray()); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, ""); + return new FileInfo(path); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/WorkspaceSetupServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/WorkspaceSetupServiceTests.cs index 32f7c6a5..e1ad9270 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/WorkspaceSetupServiceTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/WorkspaceSetupServiceTests.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection; using WinApp.Cli.Models; using WinApp.Cli.Services; +using WinApp.Cli.Tests.TestDoubles; namespace WinApp.Cli.Tests; @@ -184,20 +185,17 @@ public async Task SetupWorkspace_WithRequireExistingConfig_NoOpsWhenConfigMissin // Act var exitCode = await workspaceSetupService.SetupWorkspaceAsync(options, TestContext.CancellationToken); - // Assert - // Restore on a non-.NET project with no winapp.yaml is a graceful no-op: - // a project that doesn't declare SDK package versions has nothing to restore. - // (.NET projects without yaml are still rejected — handled separately by the - // csproj-detection branch in SetupWorkspaceAsync.) + // Restore on a non-.NET project without winapp.yaml is a no-op: + // nothing declared = nothing to restore. (.NET projects without yaml + // are rejected elsewhere.) Assert.AreEqual(0, exitCode, "Restore should be a no-op (exit 0) when no winapp.yaml exists on a non-.NET project"); } } /// -/// End-to-end tests for the merged .NET and native workspace setup code paths. -/// These tests verify that the unified WorkspaceSetupService correctly handles -/// both .NET (csproj) and native (C++) projects through the shared flow, -/// including the key fix: Windows App SDK Runtime installation on the .NET path. +/// End-to-end tests for the merged .NET / native workspace setup. Verifies the +/// unified WorkspaceSetupService handles both csproj and C++ projects through +/// the shared flow, including Windows App SDK Runtime install on .NET. /// [TestClass] public class WorkspaceSetupServiceMergedPathTests : BaseCommandTests @@ -500,11 +498,8 @@ public async Task SetupWorkspace_DotNet_AttemptsRuntimeInstall() // (runtime install failure is non-blocking) Assert.AreEqual(0, exitCode, "Setup should complete despite runtime install not finding MSIX packages"); - // Verify the runtime install was ATTEMPTED by checking output for the - // runtime install step. This is the key behavioral change from the merge: - // before, .NET projects never reached this code path. - // Note: Non-error log messages go to static AnsiConsole, error logs to ConsoleStdErr, - // and Spectre status display goes to TestAnsiConsole + // Verify the runtime install step was reached. (Pre-merge, .NET + // projects never hit this code path.) var ansiOutput = TestAnsiConsole.Output; var logOutput = ConsoleStdErr.ToString(); var combinedOutput = ansiOutput + logOutput; @@ -993,3 +988,196 @@ public async Task SetupWorkspace_DotNet_InitSucceeds_WhenOptionalWinAppPackageAd #endregion } + +// JS-bindings step propagation tests. Drive +// WorkspaceSetupService.MaybeRunJsBindingsStepAsync directly with a fake +// IJsBindingsWorkspaceService to verify the propagation contract without +// the full restore/install dependency tree. +[TestClass] +public class WorkspaceSetupServiceJsBindingsStepTests : BaseCommandTests +{ + private FakeJsBindingsWorkspaceService _fakeJsBindings = null!; + + protected override IServiceCollection ConfigureServices(IServiceCollection services) + { + _fakeJsBindings = new FakeJsBindingsWorkspaceService(); + var existing = services.FirstOrDefault(d => d.ServiceType == typeof(IJsBindingsWorkspaceService)); + if (existing is not null) + { + services.Remove(existing); + } + services.AddSingleton(_fakeJsBindings); + return services; + } + + private WorkspaceSetupService GetSut() => (WorkspaceSetupService)GetRequiredService(); + + private static WorkspaceSetupOptions MinimalOptions(DirectoryInfo baseDir) => new() + { + BaseDirectory = baseDir, + ConfigDir = baseDir, + SdkInstallMode = SdkInstallMode.None, + }; + + [TestMethod] + public async Task MaybeRunJsBindingsStepAsync_NullConfig_ReturnsNull() + { + // Gate 1: no winapp config → step is a no-op. + var result = await GetSut().MaybeRunJsBindingsStepAsync( + config: null, + usedVersions: new Dictionary(), + nugetCacheDir: _tempDirectory, + localWinappDir: _testWinappDirectory, + options: MinimalOptions(_tempDirectory), + taskContext: TestTaskContext, + cancellationToken: TestContext.CancellationToken); + + Assert.IsNull(result, "Null config must short-circuit the step."); + Assert.AreEqual(0, _fakeJsBindings.Calls.Count, + "RunAsync MUST NOT be invoked when there's no jsBindings config."); + } + + [TestMethod] + public async Task MaybeRunJsBindingsStepAsync_NoJsBindingsBlock_ReturnsNull() + { + // Gate 2: config exists but jsBindings: block is absent → no-op. + var config = new WinappConfig + { + Packages = { new PackagePin { Name = "Microsoft.WindowsAppSDK", Version = "1.8.39" } }, + JsBindings = null, + }; + + var result = await GetSut().MaybeRunJsBindingsStepAsync( + config, + usedVersions: new Dictionary(), + nugetCacheDir: _tempDirectory, + localWinappDir: _testWinappDirectory, + options: MinimalOptions(_tempDirectory), + taskContext: TestTaskContext, + cancellationToken: TestContext.CancellationToken); + + Assert.IsNull(result); + Assert.AreEqual(0, _fakeJsBindings.Calls.Count); + } + + [TestMethod] + public async Task MaybeRunJsBindingsStepAsync_NullUsedVersionsOrDirs_ReturnsNull() + { + // Gates 3/4/5: any of usedVersions / nugetCacheDir / localWinappDir + // null means restore hasn't produced enough state to invoke + // bindings. All three must short-circuit. + var config = new WinappConfig + { + JsBindings = new JsBindingsConfig { Output = "bindings/winrt", Lang = "js" }, + }; + + var sut = GetSut(); + Assert.IsNull(await sut.MaybeRunJsBindingsStepAsync( + config, usedVersions: null, _tempDirectory, _testWinappDirectory, + MinimalOptions(_tempDirectory), TestTaskContext, TestContext.CancellationToken)); + Assert.IsNull(await sut.MaybeRunJsBindingsStepAsync( + config, new Dictionary(), nugetCacheDir: null, _testWinappDirectory, + MinimalOptions(_tempDirectory), TestTaskContext, TestContext.CancellationToken)); + Assert.IsNull(await sut.MaybeRunJsBindingsStepAsync( + config, new Dictionary(), _tempDirectory, localWinappDir: null, + MinimalOptions(_tempDirectory), TestTaskContext, TestContext.CancellationToken)); + + Assert.AreEqual(0, _fakeJsBindings.Calls.Count, + "RunAsync MUST NOT be invoked when any gating dependency is null."); + } + + [TestMethod] + public async Task MaybeRunJsBindingsStepAsync_RunAsyncReturnsZero_ReturnsNull() + { + // Happy path: all gates open, RunAsync reports success → caller + // treats this as a no-op and continues with the rest of init. + _fakeJsBindings.Result = new JsBindingsOrchestrationResult + { + ExitCode = 0, + Message = "ok", + }; + var config = new WinappConfig + { + JsBindings = new JsBindingsConfig { Output = "bindings/winrt", Lang = "js" }, + }; + + var result = await GetSut().MaybeRunJsBindingsStepAsync( + config, + new Dictionary { ["Microsoft.WindowsAppSDK"] = "1.8.39" }, + _tempDirectory, + _testWinappDirectory, + MinimalOptions(_tempDirectory), + TestTaskContext, + TestContext.CancellationToken); + + Assert.IsNull(result, "Success must return null so init proceeds."); + Assert.AreEqual(1, _fakeJsBindings.Calls.Count, "RunAsync must be invoked exactly once."); + } + + [TestMethod] + public async Task MaybeRunJsBindingsStepAsync_RunAsyncReturnsNonZero_PropagatesExitAndMessage() + { + // the core propagation contract. When the + // bindings sub-task fails, init MUST surface the same exit code + // — otherwise the user sees a green "init complete" while their + // bindings dir is empty / partial. + _fakeJsBindings.Result = new JsBindingsOrchestrationResult + { + ExitCode = 7, + Message = "simulated bindings failure", + }; + var config = new WinappConfig + { + JsBindings = new JsBindingsConfig { Output = "bindings/winrt", Lang = "js" }, + }; + + var result = await GetSut().MaybeRunJsBindingsStepAsync( + config, + new Dictionary { ["Microsoft.WindowsAppSDK"] = "1.8.39" }, + _tempDirectory, + _testWinappDirectory, + MinimalOptions(_tempDirectory), + TestTaskContext, + TestContext.CancellationToken); + + Assert.IsNotNull(result, "Failure must return a non-null tuple so caller can propagate."); + Assert.AreEqual(7, result.Value.Item1, + "Exit code from JsBindingsOrchestrationResult.ExitCode must propagate verbatim."); + StringAssert.Contains(result.Value.Item2, "simulated bindings failure", + "Message must propagate so the user can diagnose."); + Assert.AreEqual(1, _fakeJsBindings.Calls.Count); + } + + [TestMethod] + public async Task MaybeRunJsBindingsStepAsync_ForwardsContextFieldsCorrectly() + { + // Regression guard: the context passed to RunAsync must include + // exactly the fields the production SetupWorkspaceAsync passes — + // no field drift between callsite and helper. + _fakeJsBindings.Result = new JsBindingsOrchestrationResult { ExitCode = 0, Message = "ok" }; + var config = new WinappConfig + { + JsBindings = new JsBindingsConfig { Output = "bindings/winrt", Lang = "js" }, + }; + var usedVersions = new Dictionary + { + ["Microsoft.WindowsAppSDK"] = "1.8.39", + ["Microsoft.WindowsAppSDK.AI"] = "1.8.39", + }; + + await GetSut().MaybeRunJsBindingsStepAsync( + config, usedVersions, _tempDirectory, _testWinappDirectory, + MinimalOptions(_tempDirectory), TestTaskContext, TestContext.CancellationToken); + + Assert.AreEqual(1, _fakeJsBindings.Calls.Count); + var captured = _fakeJsBindings.Calls[0]; + Assert.AreSame(config.JsBindings, captured.JsBindingsConfig); + Assert.AreSame(config, captured.WinappConfig); + Assert.AreEqual(_tempDirectory.FullName, captured.WorkspaceDir.FullName); + Assert.AreEqual(_testWinappDirectory.FullName, captured.LocalWinappDir.FullName); + Assert.AreEqual(_tempDirectory.FullName, captured.NugetCacheDir.FullName); + Assert.IsNotNull(captured.UsedVersions); + Assert.AreEqual(2, captured.UsedVersions!.Count); + Assert.AreEqual("1.8.39", captured.UsedVersions["Microsoft.WindowsAppSDK.AI"]); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/YamlPackagesHasherTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/YamlPackagesHasherTests.cs new file mode 100644 index 00000000..a59c39e0 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/YamlPackagesHasherTests.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.Models; +using WinApp.Cli.Services; + +namespace WinApp.Cli.Tests; + +[TestClass] +public class YamlPackagesHasherTests +{ + [TestMethod] + public void Compute_EmptyInput_ReturnsStableHash() + { + var h = YamlPackagesHasher.Compute(Array.Empty()); + Assert.IsFalse(string.IsNullOrEmpty(h)); + Assert.AreEqual(64, h.Length, "SHA-256 hex digest should be 64 chars."); + } + + [TestMethod] + public void Compute_SameInputs_SameHash_IndependentOfOrder() + { + // Hash must be order-independent (yaml order vs dict order may differ). + var a = new[] + { + new PackagePin { Name = "Foo", Version = "1.0" }, + new PackagePin { Name = "Bar", Version = "2.0" }, + }; + var b = new[] + { + new PackagePin { Name = "Bar", Version = "2.0" }, + new PackagePin { Name = "Foo", Version = "1.0" }, + }; + Assert.AreEqual(YamlPackagesHasher.Compute(a), YamlPackagesHasher.Compute(b)); + } + + [TestMethod] + public void Compute_DifferentVersions_DifferentHash() + { + var a = new[] { new PackagePin { Name = "Foo", Version = "1.0" } }; + var b = new[] { new PackagePin { Name = "Foo", Version = "1.1" } }; + Assert.AreNotEqual(YamlPackagesHasher.Compute(a), YamlPackagesHasher.Compute(b)); + } + + [TestMethod] + public void Compute_CaseInsensitiveOnName_VersionExactMatch() + { + // NuGet treats package IDs case-insensitively; hash must too. + var a = new[] { new PackagePin { Name = "Foo", Version = "1.0" } }; + var b = new[] { new PackagePin { Name = "FOO", Version = "1.0" } }; + Assert.AreEqual(YamlPackagesHasher.Compute(a), YamlPackagesHasher.Compute(b)); + } + + [TestMethod] + public void Compute_AddedPackage_DifferentHash() + { + var a = new[] { new PackagePin { Name = "Foo", Version = "1.0" } }; + var b = new[] + { + new PackagePin { Name = "Foo", Version = "1.0" }, + new PackagePin { Name = "Bar", Version = "2.0" }, + }; + Assert.AreNotEqual(YamlPackagesHasher.Compute(a), YamlPackagesHasher.Compute(b)); + } + + [TestMethod] + public void Compute_SkipsBlankNames() + { + // Defensive: a yaml glitch shouldn't crash hashing. + var a = new[] + { + new PackagePin { Name = "Foo", Version = "1.0" }, + new PackagePin { Name = "", Version = "ignored" }, + new PackagePin { Name = " ", Version = "alsoIgnored" }, + }; + var b = new[] { new PackagePin { Name = "Foo", Version = "1.0" } }; + Assert.AreEqual(YamlPackagesHasher.Compute(a), YamlPackagesHasher.Compute(b)); + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Commands/AddJsBindingsCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/AddJsBindingsCommand.cs new file mode 100644 index 00000000..38a632a1 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Commands/AddJsBindingsCommand.cs @@ -0,0 +1,158 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.CommandLine; +using System.CommandLine.Invocation; +using WinApp.Cli.Services; + +namespace WinApp.Cli.Commands; + +// `node jsbindings add` — layered, non-destructive. Requires existing +// winapp.yaml; never touches packages: or installs SDK packages. +internal class AddJsBindingsCommand : Command, IShortDescription +{ + public string ShortDescription => "Add JS/TS bindings to an existing workspace"; + + // Caller value emitted by the npm winapp shim; required to use this command. + private const string NpmShimCaller = "nodejs-package"; + + public static Argument BaseDirectoryArgument { get; } + public static Option ConfigDirOption { get; } + public static Option OutputOption { get; } + public static Option ForceOption { get; } + public static Option UseDefaultsOption { get; } + + // Per-preset --{preset} alias flags. Auto-populated from + // JsBindingsPresets.KnownPresets. + public static IReadOnlyDictionary> PresetAliasOptions { get; } + + static AddJsBindingsCommand() + { + BaseDirectoryArgument = new Argument("base-directory") + { + Description = "Base/root directory for the winapp workspace (default: current directory)", + Arity = ArgumentArity.ZeroOrOne, + }; + BaseDirectoryArgument.AcceptExistingOnly(); + + ConfigDirOption = new Option("--config-dir") + { + Description = "Directory containing winapp.yaml (default: base-directory)", + }; + ConfigDirOption.AcceptExistingOnly(); + + OutputOption = new Option("--output") + { + Description = "Output directory for generated JS/TS bindings (relative to workspace, default 'bindings/winrt'). " + + "Persisted to winapp.yaml's jsBindings.output field.", + HelpName = "PATH", + }; + + ForceOption = new Option("--force") + { + Description = "Patch an existing jsBindings: block without prompting. " + + "Overwrites only output and (when a preset like --ai is supplied) the packages list; " + + "all other fields are preserved. Without --force the command refuses to clobber a " + + "pre-existing block (interactive: prompts; non-interactive: errors).", + }; + + UseDefaultsOption = new Option("--use-defaults", "--no-prompt") + { + Description = "Do not prompt. When jsBindings: already exists in winapp.yaml, " + + "preserve it and exit 0 (idempotent). Use --force instead if you want " + + "the existing block patched non-interactively.", + }; + + var aliases = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var preset in JsBindingsPresets.KnownPresets.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase)) + { + var flag = JsBindingsPresets.AddAliasFlagName(preset); + aliases[preset] = new Option(flag) + { + Description = $"Generate bindings for the '{preset}' slice of the SDK only. " + + "For a custom slice that no preset covers, edit winapp.yaml's jsBindings.packages " + + $"after adding. Known presets: {JsBindingsPresets.KnownPresetsDisplay()}.", + }; + } + PresetAliasOptions = aliases; + } + + public AddJsBindingsCommand() : base( + "add", + "Add a jsBindings: block to winapp.yaml and run codegen. " + + "Requires winapp.yaml (run 'winapp init' first). Never modifies the packages: section " + + "or installs SDK packages — codegen runs against the workspace's already-restored packages. " + + "Refuses to clobber an existing jsBindings: block unless --force is passed. " + + "Only available when invoked via the @microsoft/winappcli npm package " + + "(npx winapp node jsbindings add).") + { + Arguments.Add(BaseDirectoryArgument); + Options.Add(ConfigDirOption); + Options.Add(OutputOption); + Options.Add(ForceOption); + Options.Add(UseDefaultsOption); + foreach (var aliasOption in PresetAliasOptions.Values) + { + Options.Add(aliasOption); + } + } + + public class Handler(IJsBindingsWorkspaceService jsBindingsWorkspaceService, ICurrentDirectoryProvider currentDirectoryProvider) : AsynchronousCommandLineAction + { + public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + { + var baseDirectory = parseResult.GetValue(BaseDirectoryArgument) ?? currentDirectoryProvider.GetCurrentDirectoryInfo(); + var configDir = parseResult.GetValue(ConfigDirOption) ?? baseDirectory; + var output = parseResult.GetValue(OutputOption); + var force = parseResult.GetValue(ForceOption); + var useDefaults = parseResult.GetValue(UseDefaultsOption); + + if (force && useDefaults) + { + var stderr = parseResult.InvocationConfiguration.Error; + await stderr.WriteLineAsync( + "Error: --force and --use-defaults are mutually exclusive. " + + "--force patches an existing jsBindings: block; --use-defaults preserves it. Pick one."); + return 1; + } + + // Iteration order is alphabetical (static ctor), so the prefix + // union below is deterministic regardless of cmdline arg order. + var enabledPresets = PresetAliasOptions + .Where(kv => parseResult.GetValue(kv.Value)) + .Select(kv => kv.Key) + .ToList(); + + // Gate behind the npm shim. Same rationale as InitCommand: the + // codegen tool is an npm transitive dep of @microsoft/winappcli; + // running this from any other entry point will fail at the + // codegen invocation with a less actionable error. + var caller = Environment.GetEnvironmentVariable("WINAPP_CLI_CALLER"); + if (!string.Equals(caller, NpmShimCaller, StringComparison.Ordinal)) + { + var stderr = parseResult.InvocationConfiguration.Error; + await stderr.WriteLineAsync( + "Error: 'node jsbindings add' requires the @microsoft/winappcli npm package."); + await stderr.WriteLineAsync( + "JS/TS bindings depend on @microsoft/dynwinrt-codegen, which is installed"); + await stderr.WriteLineAsync( + "as a transitive npm dependency. To use this command:"); + await stderr.WriteLineAsync(" npm i -D @microsoft/winappcli"); + await stderr.WriteLineAsync(" npx winapp node jsbindings add"); + return 1; + } + + var options = new AddJsBindingsOptions + { + BaseDirectory = baseDirectory, + ConfigDir = configDir, + Output = output, + Presets = enabledPresets.Count > 0 ? enabledPresets : null, + Force = force, + UseDefaults = useDefaults, + }; + + return await jsBindingsWorkspaceService.AddAsync(options, cancellationToken); + } + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Commands/GenerateJsBindingsCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/GenerateJsBindingsCommand.cs new file mode 100644 index 00000000..d7b38987 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Commands/GenerateJsBindingsCommand.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.CommandLine; +using System.CommandLine.Invocation; +using WinApp.Cli.Helpers; +using WinApp.Cli.Services; + +namespace WinApp.Cli.Commands; + +// `node jsbindings generate` — read-only codegen. Loads the existing +// jsBindings: block from winapp.yaml and runs dynwinrt-codegen against +// the workspace's already-restored packages. Does NOT mutate yaml. +internal class GenerateJsBindingsCommand : Command, IShortDescription +{ + private const string NpmShimCaller = "nodejs-package"; + + public string ShortDescription => "Re-run codegen against the existing jsBindings: block"; + + public static Argument BaseDirectoryArgument { get; } + public static Option ConfigDirOption { get; } + + static GenerateJsBindingsCommand() + { + BaseDirectoryArgument = new Argument("base-directory") + { + Description = "Base/root directory for the winapp workspace (default: current directory)", + Arity = ArgumentArity.ZeroOrOne, + }; + BaseDirectoryArgument.AcceptExistingOnly(); + + ConfigDirOption = new Option("--config-dir") + { + Description = "Directory containing winapp.yaml (default: base-directory)", + }; + ConfigDirOption.AcceptExistingOnly(); + } + + public GenerateJsBindingsCommand() : base( + "generate", + "Re-run dynwinrt-codegen against the existing jsBindings: block in winapp.yaml. " + + "Does NOT modify the yaml — for that, use 'node jsbindings add'. " + + "Errors if no jsBindings: block is declared. " + + "Only available when invoked via the @microsoft/winappcli npm package " + + "(npx winapp node jsbindings generate).") + { + Arguments.Add(BaseDirectoryArgument); + Options.Add(ConfigDirOption); + } + + public class Handler(IJsBindingsWorkspaceService jsBindingsWorkspaceService, ICurrentDirectoryProvider currentDirectoryProvider) : AsynchronousCommandLineAction + { + public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + { + var baseDirectory = parseResult.GetValue(BaseDirectoryArgument) ?? currentDirectoryProvider.GetCurrentDirectoryInfo(); + var configDir = parseResult.GetValue(ConfigDirOption) ?? baseDirectory; + + var caller = Environment.GetEnvironmentVariable("WINAPP_CLI_CALLER"); + if (!string.Equals(caller, NpmShimCaller, StringComparison.Ordinal)) + { + var stderr = parseResult.InvocationConfiguration.Error; + await stderr.WriteLineAsync( + "Error: 'node jsbindings generate' requires the @microsoft/winappcli npm package."); + await stderr.WriteLineAsync( + "JS/TS bindings depend on @microsoft/dynwinrt-codegen, which is installed"); + await stderr.WriteLineAsync( + "as a transitive npm dependency. To use this command:"); + await stderr.WriteLineAsync(" npm i -D @microsoft/winappcli"); + await stderr.WriteLineAsync(" npx winapp node jsbindings generate"); + return 1; + } + + var options = new GenerateJsBindingsOptions + { + BaseDirectory = baseDirectory, + ConfigDir = configDir, + }; + + return await jsBindingsWorkspaceService.GenerateAsync(options, cancellationToken); + } + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Commands/InitCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/InitCommand.cs index 7c2b6b4b..3777e29c 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/InitCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/InitCommand.cs @@ -18,6 +18,18 @@ internal class InitCommand : Command, IShortDescription public static Option NoGitignoreOption { get; } public static Option UseDefaults { get; } public static Option ConfigOnlyOption { get; } + public static Option JsBindingsOption { get; } + public static Option JsBindingsOutputOption { get; } + public static Option JsBindingsLangOption { get; } + + // Per-preset alias flags (e.g. --js-bindings-ai). Auto-populated from + // JsBindingsPresets.KnownPresets; each implies --js-bindings, and + // multiple combine via union. + public static IReadOnlyDictionary> JsBindingsPresetAliasOptions { get; } + + // WINAPP_CLI_CALLER emitted by the npm shim. --js-bindings requires this + // caller because codegen ships as an npm transitive dep. + private const string NpmShimCaller = "nodejs-package"; static InitCommand() { @@ -53,6 +65,41 @@ static InitCommand() { Description = "Only handle configuration file operations (create if missing, validate if exists). Skip package installation and other workspace setup steps." }; + JsBindingsOption = new Option("--js-bindings") + { + Description = "Generate JS/TS bindings via dynwinrt-codegen on top of the standard init flow. " + + "Adds a 'jsBindings:' block to winapp.yaml so the binding generator runs as part of init/restore. " + + "Only available when invoked via the @microsoft/winappcli npm package (npx winapp init --js-bindings)." + }; + JsBindingsOutputOption = new Option("--js-bindings-output") + { + Description = "Override the output directory for generated JS/TS bindings (relative to workspace, default 'bindings/winrt'). " + + "Only takes effect together with --js-bindings on a fresh init; ignored on re-init when winapp.yaml already declares jsBindings:.", + HelpName = "PATH", + }; + JsBindingsLangOption = new Option("--js-bindings-lang") + { + Description = "Override the JS bindings language. Currently only 'js' is supported (which emits both .js and .d.ts). " + + "Reserved for forward-compat; see --js-bindings-output for activation rules.", + HelpName = "js", + }; + JsBindingsLangOption.AcceptOnlyFromAmong("js"); + + // One --js-bindings-{preset} flag per preset, alphabetised. + var aliases = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var preset in JsBindingsPresets.KnownPresets.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase)) + { + var flag = JsBindingsPresets.AliasFlagName(preset); + aliases[preset] = new Option(flag) + { + Description = $"Generate bindings for the '{preset}' slice of the SDK. " + + "Implies --js-bindings (no need to pass it separately). " + + "For a custom slice that no preset covers, edit winapp.yaml " + + "and write your own packages: list under jsBindings. " + + $"Known presets: {JsBindingsPresets.KnownPresetsDisplay()}.", + }; + } + JsBindingsPresetAliasOptions = aliases; } public InitCommand() : base("init", "Start here for initializing a Windows app with required setup. Sets up everything needed for Windows app development: creates Package.appxmanifest with default assets, downloads Windows SDK and Windows App SDK packages, and generates projections. When SDK packages are managed (--setup-sdks stable/preview/experimental), also creates winapp.yaml to pin versions for 'restore'/'update'; with --setup-sdks none (e.g., for Rust/Tauri projects that bring their own SDK bindings), no winapp.yaml is created. Interactive by default (use --use-defaults to skip prompts). Use 'restore' instead if you cloned a repo that already has winapp.yaml. Use 'manifest generate' if you only need a manifest, or 'cert generate' if you need a development certificate for code signing.") @@ -64,6 +111,13 @@ static InitCommand() Options.Add(NoGitignoreOption); Options.Add(UseDefaults); Options.Add(ConfigOnlyOption); + Options.Add(JsBindingsOption); + Options.Add(JsBindingsOutputOption); + Options.Add(JsBindingsLangOption); + foreach (var aliasOption in JsBindingsPresetAliasOptions.Values) + { + Options.Add(aliasOption); + } } public class Handler(IWorkspaceSetupService workspaceSetupService, ICurrentDirectoryProvider currentDirectoryProvider) : AsynchronousCommandLineAction @@ -77,6 +131,55 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio var noGitignore = parseResult.GetValue(NoGitignoreOption); var useDefaults = parseResult.GetValue(UseDefaults); var configOnly = parseResult.GetValue(ConfigOnlyOption); + var jsBindings = parseResult.GetValue(JsBindingsOption); + var jsBindingsOutput = parseResult.GetValue(JsBindingsOutputOption); + var jsBindingsLang = parseResult.GetValue(JsBindingsLangOption); + + // Iteration order is alphabetical (registration order), so the + // resulting prefix union is deterministic. + var enabledAliases = JsBindingsPresetAliasOptions + .Where(kv => parseResult.GetValue(kv.Value)) + .Select(kv => kv.Key) + .ToList(); + + // Aliases imply --js-bindings (the whole point of the shorthand). + // Promote BEFORE the warning check below. + if (enabledAliases.Count > 0 && !jsBindings) + { + jsBindings = true; + } + + if (!jsBindings && + (!string.IsNullOrWhiteSpace(jsBindingsOutput) + || !string.IsNullOrWhiteSpace(jsBindingsLang))) + { + var stderr = parseResult.InvocationConfiguration.Error; + await stderr.WriteLineAsync( + "Error: --js-bindings-output / --js-bindings-lang require --js-bindings. " + + "Add --js-bindings (or one of the alias flags like --js-bindings-ai) to enable bindings generation."); + return 1; + } + + // Gate --js-bindings behind the npm shim — codegen ships as an + // npm transitive dep of @microsoft/winappcli, so non-npm callers + // would silently produce a broken workspace. + if (jsBindings) + { + var caller = Environment.GetEnvironmentVariable("WINAPP_CLI_CALLER"); + if (!string.Equals(caller, NpmShimCaller, StringComparison.Ordinal)) + { + var stderr = parseResult.InvocationConfiguration.Error; + await stderr.WriteLineAsync( + "Error: --js-bindings requires the @microsoft/winappcli npm package."); + await stderr.WriteLineAsync( + "JS/TS bindings depend on @microsoft/dynwinrt-codegen, which is installed"); + await stderr.WriteLineAsync( + "as a transitive npm dependency. To use this flag:"); + await stderr.WriteLineAsync(" npm i -D @microsoft/winappcli"); + await stderr.WriteLineAsync(" npx winapp init --js-bindings"); + return 1; + } + } var options = new WorkspaceSetupOptions { @@ -88,7 +191,11 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio UseDefaults = useDefaults, RequireExistingConfig = false, ForceLatestBuildTools = true, - ConfigOnly = configOnly + ConfigOnly = configOnly, + AddJsBindings = jsBindings, + JsBindingsOutputOverride = jsBindings ? jsBindingsOutput : null, + JsBindingsLangOverride = jsBindings ? jsBindingsLang : null, + JsBindingsPresets = jsBindings && enabledAliases.Count > 0 ? enabledAliases : null, }; return await workspaceSetupService.SetupWorkspaceAsync(options, cancellationToken); diff --git a/src/winapp-CLI/WinApp.Cli/Commands/JsBindingsCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/JsBindingsCommand.cs new file mode 100644 index 00000000..c4b48035 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Commands/JsBindingsCommand.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.CommandLine; + +namespace WinApp.Cli.Commands; + +// `node jsbindings` verb. Hosts subcommands that operate on the +// jsBindings: block of winapp.yaml — `add` (mutates yaml + codegen) and +// `generate` (read-only codegen). +internal class JsBindingsCommand : Command, IShortDescription +{ + public string ShortDescription => "Manage JS/TS WinRT bindings (npm-only)"; + + public JsBindingsCommand( + AddJsBindingsCommand addJsBindingsCommand, + GenerateJsBindingsCommand generateJsBindingsCommand) + : base("jsbindings", "Manage JS/TS WinRT bindings for an existing workspace. " + + "'add' mutates winapp.yaml + runs codegen; 'generate' just runs codegen " + + "against the existing yaml. Only available via the @microsoft/winappcli npm package.") + { + // Kebab-case alias — matches the --js-bindings flag name in init. + Aliases.Add("js-bindings"); + Subcommands.Add(addJsBindingsCommand); + Subcommands.Add(generateJsBindingsCommand); + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Commands/NodeCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/NodeCommand.cs new file mode 100644 index 00000000..2a3c3471 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Commands/NodeCommand.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.CommandLine; + +namespace WinApp.Cli.Commands; + +// `node` verb in the .NET CLI tree. Hosts Node.js / Electron-specific +// sub-commands that need real CLI work (currently `jsbindings`). +// Wrapper-only commands (`create-addon`, `add-electron-debug-identity`, +// `clear-electron-debug-identity`) are implemented in the npm shim and +// never reach this command — the shim intercepts them locally. +internal class NodeCommand : Command, IShortDescription +{ + public string ShortDescription => "Node.js / Electron-specific commands (npm-only)"; + + public NodeCommand(JsBindingsCommand jsBindingsCommand) + : base("node", "Node.js / Electron-specific winapp commands. Only available when invoked via " + + "the @microsoft/winappcli npm package (npx winapp node ...).") + { + Subcommands.Add(jsBindingsCommand); + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs index 766e777e..c4767a74 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs @@ -71,7 +71,8 @@ public WinAppRootCommand( IAnsiConsole ansiConsole, CreateExternalCatalogCommand createExternalCatalogCommand, CompleteCommand completeCommand, - UiCommand uiCommand) : base("CLI for Windows app development, including package identity, packaging, managing Package.appxmanifest, test certificates, Windows (App) SDK projections, and more. For use with any app framework targeting Windows") + UiCommand uiCommand, + NodeCommand nodeCommand) : base("CLI for Windows app development, including package identity, packaging, managing Package.appxmanifest, test certificates, Windows (App) SDK projections, and more. For use with any app framework targeting Windows") { Subcommands.Add(initCommand); Subcommands.Add(restoreCommand); @@ -89,6 +90,7 @@ public WinAppRootCommand( Subcommands.Add(createExternalCatalogCommand); Subcommands.Add(uiCommand); Subcommands.Add(completeCommand); + Subcommands.Add(nodeCommand); Options.Add(CliSchemaOption); Options.Add(CallerOption); @@ -102,6 +104,7 @@ public WinAppRootCommand( ("Setup", [typeof(InitCommand), typeof(RestoreCommand), typeof(UpdateCommand)]), ("Packaging & Signing", [typeof(PackageCommand), typeof(SignCommand), typeof(CertCommand), typeof(ManifestCommand), typeof(CreateExternalCatalogCommand)]), ("Development Tools", [typeof(CreateDebugIdentityCommand), typeof(MSStoreCommand), typeof(ToolCommand), typeof(GetWinappPathCommand), typeof(RunCommand), typeof(UnregisterCommand)]), + ("Node.js / Electron", [typeof(NodeCommand)]), ("UI Automation", [typeof(UiCommand)]) ); } diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs b/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs index 5db8f5b0..77bcea74 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs @@ -21,6 +21,11 @@ public static IServiceCollection ConfigureServices(this IServiceCollection servi .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() @@ -34,6 +39,7 @@ public static IServiceCollection ConfigureServices(this IServiceCollection servi .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() @@ -76,6 +82,11 @@ public static IServiceCollection ConfigureCommands(this IServiceCollection servi .UseCommandHandler() .UseCommandHandler(false) .UseCommandHandler() + // `node` verb tree (Node.js / Electron-specific, npm-only) + .ConfigureCommand() + .ConfigureCommand() + .UseCommandHandler() + .UseCommandHandler() // UI Automation commands .ConfigureCommand() .UseCommandHandler() diff --git a/src/winapp-CLI/WinApp.Cli/Models/JsBindingsConfig.cs b/src/winapp-CLI/WinApp.Cli/Models/JsBindingsConfig.cs new file mode 100644 index 00000000..f5d8918b --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Models/JsBindingsConfig.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +namespace WinApp.Cli.Models; + +// User-facing configuration for JS/TS bindings generated from WinRT metadata. +// Materialized from the optional jsBindings: block in winapp.yaml. +internal sealed class JsBindingsConfig +{ + // Target language. Currently js (default) or py. + public string Lang { get; set; } = "js"; + + // Output directory, relative to the workspace root. + public string Output { get; set; } = "bindings/winrt"; + + // NuGet package IDs to scope binding generation to (empty = all). + public List Packages { get; set; } = new(); + + // Individual classes to generate alongside the bulk pass. + public List ExtraTypes { get; set; } = new(); + + // Extra .winmd files to emit bindings for. + public List AdditionalWinmds { get; set; } = new(); + + // Extra .winmd files loaded for type resolution only. + public List AdditionalRefs { get; set; } = new(); + + // NuGet package IDs to drop entirely. + public List SkipPackages { get; set; } = new(); + + // NuGet package IDs to load as --ref only. + public List RefOnlyPackages { get; set; } = new(); + + // NuGet package IDs to force-emit, overriding skip / ref-only. + public List EmitPackages { get; set; } = new(); +} + +// One namespace + class-name list for selective generation. +internal sealed class JsBindingsExtraType +{ + public string Namespace { get; set; } = string.Empty; + public List Classes { get; set; } = new(); +} diff --git a/src/winapp-CLI/WinApp.Cli/Models/WinappConfig.cs b/src/winapp-CLI/WinApp.Cli/Models/WinappConfig.cs index b3f427fc..fe5e3ec2 100644 --- a/src/winapp-CLI/WinApp.Cli/Models/WinappConfig.cs +++ b/src/winapp-CLI/WinApp.Cli/Models/WinappConfig.cs @@ -7,6 +7,9 @@ internal sealed class WinappConfig { public List Packages { get; set; } = new(); + // Optional JS/TS bindings; when set, restore runs the codegen step. + public JsBindingsConfig? JsBindings { get; set; } + public string? GetVersion(string name) => Packages.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase))?.Version; diff --git a/src/winapp-CLI/WinApp.Cli/Models/WinmdsLockfile.cs b/src/winapp-CLI/WinApp.Cli/Models/WinmdsLockfile.cs new file mode 100644 index 00000000..c1389210 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Models/WinmdsLockfile.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace WinApp.Cli.Models; + +// Lockfile written by `winapp restore`, consumed by `node jsbindings add`. Optional. +internal sealed class WinmdsLockfile +{ + // Current schema version. Bump on breaking shape changes. + public const int CurrentSchema = 2; + + // Schema version this file was produced with. + public int Schema { get; set; } = CurrentSchema; + + // ISO-8601 UTC timestamp the lockfile was written. + public string GeneratedAt { get; set; } = string.Empty; + + // NuGet global packages dir at write time. Diagnostic-only. + public string? NugetCacheDir { get; set; } + + // SHA-256 of the yaml packages: block. node jsbindings add + // treats the lockfile as stale on mismatch. + public string? YamlPackagesHash { get; set; } + + // One entry per resolved package (direct + transitive). + public List Packages { get; set; } = new(); +} + +// One package entry in WinmdsLockfile. +internal sealed class WinmdsLockfilePackage +{ + // NuGet package ID (original casing). + public string Name { get; set; } = string.Empty; + + // Resolved version. + public string Version { get; set; } = string.Empty; + + // emit / refOnly / skip. + public string Category { get; set; } = "emit"; + + // Absolute paths of every .winmd found for this package. + public List Winmds { get; set; } = new(); +} + +// Source-generated JSON context: snake_case, indented, LF endings. +[JsonSerializable(typeof(WinmdsLockfile))] +[JsonSourceGenerationOptions( + WriteIndented = true, + NewLine = "\n", + PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +internal partial class WinmdsLockfileJsonContext : JsonSerializerContext; diff --git a/src/winapp-CLI/WinApp.Cli/Services/ConfigService.cs b/src/winapp-CLI/WinApp.Cli/Services/ConfigService.cs index dad7f7a0..11cc03e6 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/ConfigService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/ConfigService.cs @@ -35,48 +35,463 @@ public WinappConfig Load() public void Save(WinappConfig cfg) { + // Full serialization — drops comments / unknown fields. var yaml = Stringify(cfg); File.WriteAllText(ConfigPath.FullName, yaml, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); ConfigPath.Refresh(); } + public void SaveJsBindingsOnly(WinappConfig cfg) + { + string yaml; + if (ConfigPath.Exists) + { + string existing; + try + { + existing = File.ReadAllText(ConfigPath.FullName); + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"Could not read existing winapp.yaml at {ConfigPath.FullName} to splice " + + "jsBindings while preserving comments. Close any editor/process that may " + + "be holding the file open, then retry. " + + $"Underlying error: {ex.Message}", ex); + } + + try + { + yaml = SpliceJsBindingsBlock(existing, cfg.JsBindings); + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"Could not splice jsBindings: block into winapp.yaml at {ConfigPath.FullName} " + + "without losing comments or unknown fields. The file's structure may be " + + "malformed; fix it manually or remove the jsBindings: block and re-run. " + + $"Underlying error: {ex.Message}", ex); + } + } + else + { + yaml = Stringify(cfg); + } + File.WriteAllText(ConfigPath.FullName, yaml, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + ConfigPath.Refresh(); + } + + // Splice a new jsBindings: block into existingYaml. Block bounds: a + // zero-indent "jsBindings:" line → next zero-indent non-blank line (or + // EOF). null `newJsBindings` removes the block. + internal static string SpliceJsBindingsBlock(string existingYaml, Models.JsBindingsConfig? newJsBindings) + { + string? replacement = null; + if (newJsBindings is not null) + { + var sb = new StringBuilder(); + AppendJsBindingsBlock(sb, newJsBindings); + replacement = sb.ToString(); + } + + // Line-by-line scan; preserve original newline style. + var lines = existingYaml.Split('\n'); + int blockStart = -1; + int blockEnd = -1; // exclusive end (next line index) + for (int i = 0; i < lines.Length; i++) + { + var trimmed = lines[i].TrimEnd('\r'); + if (IsTopLevelKey(trimmed, "jsBindings:")) + { + if (lines[i].Length > 0 && char.IsWhiteSpace(lines[i][0])) + { + continue; // nested, not a top-level key + } + blockStart = i; + // Find block end: next zero-indent non-blank line, or EOF. + // Zero-indent comments belong to the *next* top-level section + // (or to the file tail), not to jsBindings — preserve them. + blockEnd = lines.Length; + for (int j = i + 1; j < lines.Length; j++) + { + var t = lines[j].TrimEnd('\r'); + if (t.Length == 0) + { + continue; // blank lines belong to no block + } + if (!char.IsWhiteSpace(lines[j][0])) + { + // Any zero-indent line (key OR comment) ends the block. + blockEnd = j; + break; + } + } + break; + } + } + + if (blockStart >= 0) + { + var before = string.Join('\n', lines.Take(blockStart)); + var after = string.Join('\n', lines.Skip(blockEnd)); + var middle = replacement ?? string.Empty; + // Careful newline stitching — avoid double blanks / dropped trailing newline. + var result = new StringBuilder(); + if (before.Length > 0) + { + result.Append(before); + if (!before.EndsWith('\n')) + { + result.Append('\n'); + } + } + if (middle.Length > 0) + { + result.Append(middle); + if (!middle.EndsWith('\n')) + { + result.Append('\n'); + } + } + if (after.Length > 0) + { + result.Append(after); + } + return result.ToString(); + } + + // No existing block — append the new one (if any) at the end. + if (replacement is null) + { + return existingYaml; + } + var trailing = existingYaml.EndsWith('\n') ? string.Empty : "\n"; + return existingYaml + trailing + replacement; + } + private static WinappConfig Parse(string yaml) { var cfg = new WinappConfig(); using var sr = new StringReader(yaml); string? line; string? currentName = null; + var section = Section.None; + + // jsBindings sub-state + JsBindingsConfig? js = null; + var jsList = JsListMode.None; + JsBindingsExtraType? currentExtra = null; + bool inClassesList = false; + while ((line = sr.ReadLine()) != null) { + // Preserve raw indent for nested-list tracking, then trim for content match. + var indent = LeadingSpaceCount(line); var t = line.Trim(); if (t.StartsWith('#') || t.Length == 0) { continue; } - if (t.Equals("packages:", StringComparison.OrdinalIgnoreCase)) + // Top-level section switches (no indent). + if (indent == 0) { + if (t.Equals("packages:", StringComparison.OrdinalIgnoreCase)) + { + section = Section.Packages; + currentName = null; + continue; + } + // Accept `jsBindings:` followed by inline comment / trailing + // whitespace — matches SpliceJsBindingsBlock's detection so + // Load() and the splice can never disagree on whether the + // block exists. + if (IsTopLevelKey(t, "jsBindings:")) + { + section = Section.JsBindings; + js = new JsBindingsConfig(); + jsList = JsListMode.None; + currentExtra = null; + inClassesList = false; + continue; + } + + // Unknown top-level field → reset section so children don't leak. + section = Section.None; + currentName = null; + jsList = JsListMode.None; + currentExtra = null; + inClassesList = false; continue; } - if (t.StartsWith("- name:", StringComparison.OrdinalIgnoreCase)) + switch (section) + { + case Section.Packages: + if (t.StartsWith("- name:", StringComparison.OrdinalIgnoreCase)) + { + currentName = t.Substring("- name:".Length).Trim().Trim('"', '\''); + } + else if (t.StartsWith("name:", StringComparison.OrdinalIgnoreCase)) + { + currentName = t.Substring("name:".Length).Trim().Trim('"', '\''); + } + else if (t.StartsWith("version:", StringComparison.OrdinalIgnoreCase) && currentName is not null) + { + var version = t.Substring("version:".Length).Trim().Trim('"', '\''); + cfg.Packages.Add(new PackagePin { Name = currentName, Version = version }); + currentName = null; + } + break; + + case Section.JsBindings: + ParseJsBindingsLine(js!, t, ref jsList, ref currentExtra, ref inClassesList); + break; + } + } + + if (js is not null) + { + cfg.JsBindings = js; + } + return cfg; + } + + private static void ParseJsBindingsLine( + JsBindingsConfig js, + string t, + ref JsListMode listMode, + ref JsBindingsExtraType? currentExtra, + ref bool inClassesList) + { + // Scalar keys reset list state. + if (TryReadScalar(t, "lang:", out var v)) { js.Lang = v; listMode = JsListMode.None; currentExtra = null; inClassesList = false; return; } + if (TryReadScalar(t, "output:", out v)) { js.Output = v; listMode = JsListMode.None; currentExtra = null; inClassesList = false; return; } + + if (t.Equals("packages:", StringComparison.OrdinalIgnoreCase)) + { + listMode = JsListMode.Packages; + currentExtra = null; + inClassesList = false; + return; + } + if (t.Equals("additionalWinmds:", StringComparison.OrdinalIgnoreCase)) + { + listMode = JsListMode.AdditionalWinmds; + currentExtra = null; + inClassesList = false; + return; + } + if (t.Equals("additionalRefs:", StringComparison.OrdinalIgnoreCase)) + { + listMode = JsListMode.AdditionalRefs; + currentExtra = null; + inClassesList = false; + return; + } + if (t.Equals("skipPackages:", StringComparison.OrdinalIgnoreCase)) + { + listMode = JsListMode.SkipPackages; + currentExtra = null; + inClassesList = false; + return; + } + if (t.Equals("refOnlyPackages:", StringComparison.OrdinalIgnoreCase)) + { + listMode = JsListMode.RefOnlyPackages; + currentExtra = null; + inClassesList = false; + return; + } + if (t.Equals("emitPackages:", StringComparison.OrdinalIgnoreCase)) + { + listMode = JsListMode.EmitPackages; + currentExtra = null; + inClassesList = false; + return; + } + if (t.Equals("extraTypes:", StringComparison.OrdinalIgnoreCase)) + { + listMode = JsListMode.ExtraTypes; + currentExtra = null; + inClassesList = false; + return; + } + + if (listMode == JsListMode.Packages && t.StartsWith("- ", StringComparison.Ordinal)) + { + var pkg = t[2..].Trim().Trim('"', '\''); + if (!string.IsNullOrEmpty(pkg) + && !js.Packages.Contains(pkg, StringComparer.OrdinalIgnoreCase)) { - currentName = t.Substring("- name:".Length).Trim().Trim('"', '\''); + js.Packages.Add(pkg); } - else if (t.StartsWith("name:", StringComparison.OrdinalIgnoreCase)) + return; + } + + if (listMode == JsListMode.AdditionalWinmds && t.StartsWith("- ", StringComparison.Ordinal)) + { + var path = t[2..].Trim().Trim('"', '\''); + if (!string.IsNullOrEmpty(path) + && !js.AdditionalWinmds.Contains(path, StringComparer.OrdinalIgnoreCase)) { - currentName = t.Substring("name:".Length).Trim().Trim('"', '\''); + js.AdditionalWinmds.Add(path); } - else if (t.StartsWith("version:", StringComparison.OrdinalIgnoreCase) && currentName is not null) + return; + } + + if (listMode == JsListMode.AdditionalRefs && t.StartsWith("- ", StringComparison.Ordinal)) + { + var path = t[2..].Trim().Trim('"', '\''); + if (!string.IsNullOrEmpty(path) + && !js.AdditionalRefs.Contains(path, StringComparer.OrdinalIgnoreCase)) { - var version = t.Substring("version:".Length).Trim().Trim('"', '\''); - cfg.Packages.Add(new PackagePin { Name = currentName, Version = version }); - currentName = null; + js.AdditionalRefs.Add(path); } + return; } - return cfg; + + if (listMode == JsListMode.SkipPackages && t.StartsWith("- ", StringComparison.Ordinal)) + { + var pkg = t[2..].Trim().Trim('"', '\''); + if (!string.IsNullOrEmpty(pkg) + && !js.SkipPackages.Contains(pkg, StringComparer.OrdinalIgnoreCase)) + { + js.SkipPackages.Add(pkg); + } + return; + } + + if (listMode == JsListMode.RefOnlyPackages && t.StartsWith("- ", StringComparison.Ordinal)) + { + var pkg = t[2..].Trim().Trim('"', '\''); + if (!string.IsNullOrEmpty(pkg) + && !js.RefOnlyPackages.Contains(pkg, StringComparer.OrdinalIgnoreCase)) + { + js.RefOnlyPackages.Add(pkg); + } + return; + } + + if (listMode == JsListMode.EmitPackages && t.StartsWith("- ", StringComparison.Ordinal)) + { + var pkg = t[2..].Trim().Trim('"', '\''); + if (!string.IsNullOrEmpty(pkg) + && !js.EmitPackages.Contains(pkg, StringComparer.OrdinalIgnoreCase)) + { + js.EmitPackages.Add(pkg); + } + return; + } + + if (listMode == JsListMode.ExtraTypes) + { + // New item begins with `- namespace:` (the dash anchors a new entry). + if (t.StartsWith("- namespace:", StringComparison.OrdinalIgnoreCase)) + { + currentExtra = new JsBindingsExtraType + { + Namespace = t.Substring("- namespace:".Length).Trim().Trim('"', '\''), + }; + js.ExtraTypes.Add(currentExtra); + inClassesList = false; + return; + } + if (currentExtra is null) + { + return; + } + if (t.StartsWith("namespace:", StringComparison.OrdinalIgnoreCase)) + { + currentExtra.Namespace = t.Substring("namespace:".Length).Trim().Trim('"', '\''); + inClassesList = false; + return; + } + if (t.Equals("classes:", StringComparison.OrdinalIgnoreCase)) + { + inClassesList = true; + return; + } + // Inline flow-list form: `classes: [X, Y, Z]` or `classes: [X]`. + if (t.StartsWith("classes:", StringComparison.OrdinalIgnoreCase)) + { + var rest = t.Substring("classes:".Length).Trim(); + if (rest.StartsWith('[')) + { + var end = rest.IndexOf(']'); + if (end > 0) + { + var contents = rest.Substring(1, end - 1); + foreach (var item in contents.Split(',')) + { + var name = item.Trim().Trim('"', '\''); + if (!string.IsNullOrEmpty(name)) + { + currentExtra.Classes.Add(name); + } + } + inClassesList = false; + return; + } + } + // Scalar form: `classes: SingleClass` (no brackets). + if (!string.IsNullOrEmpty(rest)) + { + var name = rest.Trim('"', '\''); + currentExtra.Classes.Add(name); + inClassesList = false; + return; + } + } + if (inClassesList && t.StartsWith("- ", StringComparison.Ordinal)) + { + currentExtra.Classes.Add(t[2..].Trim().Trim('"', '\'')); + return; + } + } + } + + private static bool TryReadScalar(string t, string prefix, out string value) + { + if (t.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + value = t.Substring(prefix.Length).Trim().Trim('"', '\''); + return true; + } + value = string.Empty; + return false; + } + + private static int LeadingSpaceCount(string line) + { + int i = 0; + while (i < line.Length && line[i] == ' ') + { + i++; + } + return i; } + // Matches a top-level key like `packages:` or `jsBindings:` with any + // trailing whitespace or inline `# comment`. Used by both Parse and + // SpliceJsBindingsBlock so they never disagree on block presence. + private static bool IsTopLevelKey(string trimmedLine, string key) + { + if (!trimmedLine.StartsWith(key, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + if (trimmedLine.Length == key.Length) + { + return true; + } + var rest = trimmedLine.AsSpan(key.Length).TrimStart(); + return rest.IsEmpty || rest[0] == '#'; + } + + private enum Section { None, Packages, JsBindings } + private enum JsListMode { None, Packages, ExtraTypes, AdditionalWinmds, AdditionalRefs, SkipPackages, RefOnlyPackages, EmitPackages } + private static string Stringify(WinappConfig cfg) { var sb = new StringBuilder(); @@ -86,6 +501,84 @@ private static string Stringify(WinappConfig cfg) sb.AppendLine($" - name: {p.Name}"); sb.AppendLine($" version: {p.Version}"); } + + if (cfg.JsBindings is { } js) + { + sb.AppendLine(); + AppendJsBindingsBlock(sb, js); + } return sb.ToString(); } + + // Render the jsBindings: block. Shared by Stringify and SpliceJsBindingsBlock. + private static void AppendJsBindingsBlock(StringBuilder sb, Models.JsBindingsConfig js) + { + sb.AppendLine("jsBindings:"); + sb.AppendLine($" lang: {js.Lang}"); + sb.AppendLine($" output: {js.Output}"); + if (js.Packages.Count > 0) + { + sb.AppendLine(" packages:"); + foreach (var pkg in js.Packages) + { + sb.AppendLine($" - {pkg}"); + } + } + if (js.AdditionalWinmds.Count > 0) + { + sb.AppendLine(" additionalWinmds:"); + foreach (var path in js.AdditionalWinmds) + { + sb.AppendLine($" - {path}"); + } + } + if (js.AdditionalRefs.Count > 0) + { + sb.AppendLine(" additionalRefs:"); + foreach (var path in js.AdditionalRefs) + { + sb.AppendLine($" - {path}"); + } + } + if (js.SkipPackages.Count > 0) + { + sb.AppendLine(" skipPackages:"); + foreach (var pkg in js.SkipPackages) + { + sb.AppendLine($" - {pkg}"); + } + } + if (js.RefOnlyPackages.Count > 0) + { + sb.AppendLine(" refOnlyPackages:"); + foreach (var pkg in js.RefOnlyPackages) + { + sb.AppendLine($" - {pkg}"); + } + } + if (js.EmitPackages.Count > 0) + { + sb.AppendLine(" emitPackages:"); + foreach (var pkg in js.EmitPackages) + { + sb.AppendLine($" - {pkg}"); + } + } + if (js.ExtraTypes.Count > 0) + { + sb.AppendLine(" extraTypes:"); + foreach (var et in js.ExtraTypes) + { + sb.AppendLine($" - namespace: {et.Namespace}"); + if (et.Classes.Count > 0) + { + sb.AppendLine(" classes:"); + foreach (var cls in et.Classes) + { + sb.AppendLine($" - {cls}"); + } + } + } + } + } } diff --git a/src/winapp-CLI/WinApp.Cli/Services/DynWinrtCodegenService.cs b/src/winapp-CLI/WinApp.Cli/Services/DynWinrtCodegenService.cs new file mode 100644 index 00000000..677efb40 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/DynWinrtCodegenService.cs @@ -0,0 +1,709 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using WinApp.Cli.ConsoleTasks; +using WinApp.Cli.Helpers; +using WinApp.Cli.Models; + +namespace WinApp.Cli.Services; + +// Spawns dynwinrt-codegen against discovered .winmd metadata. +internal sealed class DynWinrtCodegenService( + INpmWrapperVersionProvider npmWrapperVersionProvider, + ILogger logger) : IDynWinrtCodegenService +{ + private const string CodegenPackageName = "@microsoft/dynwinrt-codegen"; + + // Dev/test fallback when no npm wrapper layout is available. Production + // reads the version from INpmWrapperVersionProvider (the wrapper's own + // package.json pin). Keep this in sync with src/winapp-npm/package.json. + internal const string CodegenPinnedVersionFallback = "0.1.0-preview.1"; + + // Marker written into the output dir after a successful run; its + // presence authorises the next run to wipe. + public const string ManagedMarkerFileName = ".dynwinrt-managed"; + + public async Task RunAsync( + JsBindingsConfig config, + IReadOnlyList winmds, + FileInfo? windowsSdkWinmd, + DirectoryInfo workspaceDir, + DirectoryInfo winappDir, + TaskContext taskContext, + IReadOnlyList? userAdditionalWinmds = null, + IReadOnlyList? userAdditionalRefs = null, + CancellationToken cancellationToken = default) + { + winappDir.Create(); + + var outputDir = ResolveOutputDir(workspaceDir, config.Output); + outputDir.Parent?.Create(); + + var listedWinmds = CollectListedWinmds(winmds, userAdditionalWinmds, windowsSdkWinmd); + var refWinmds = CollectRefWinmds(userAdditionalRefs, listedWinmds); + + // Locate codegen BEFORE touching the output dir so a missing install + // doesn't first wipe the user's previous bindings. Use the npm + // wrapper's pinned version for any error hints so they don't drift. + var versionHint = TryReadCodegenVersionHint(); + var (executable, prefixArgs) = ResolveCodegenInvocation(workspaceDir, versionHint); + logger.LogInformation( + "{UISymbol} Resolved dynwinrt-codegen → {Executable} {PrefixArgs}", + UiSymbols.Tools, executable, string.Join(' ', prefixArgs)); + taskContext.AddDebugMessage($"{UiSymbols.Tools} Using codegen → {executable} {string.Join(' ', prefixArgs)}"); + taskContext.AddDebugMessage($"{UiSymbols.Note} Codegen inputs: {listedWinmds.Count} emit + {refWinmds.Count} ref winmd(s)"); + + // Stage-then-swap: failure leaves previous output intact. + await RunWithStagingAsync(outputDir, async stagingDir => + { + // Skip the bulk pass when no emit winmds — extraTypes-only + // cherry-pick (refs + extraTypes, no bulk emit) still runs the + // per-extraType loop below. + if (listedWinmds.Count > 0) + { + var bulkArgs = BuildBulkArgs(prefixArgs, listedWinmds, stagingDir, config, refWinmds); + await SpawnCodegenAsync(executable, bulkArgs, workspaceDir, taskContext, cancellationToken); + } + + // One pass per extraType — cherry-picks a class from the same + // metadata universe as the bulk pass. + foreach (var et in config.ExtraTypes) + { + if (string.IsNullOrWhiteSpace(et.Namespace) || et.Classes.Count == 0) + { + continue; + } + var extraArgs = BuildExtraTypeArgs(prefixArgs, listedWinmds, stagingDir, config, refWinmds, et); + await SpawnCodegenAsync(executable, extraArgs, workspaceDir, taskContext, cancellationToken); + } + }); + + outputDir.Refresh(); + return outputDir; + } + + // Stage → backup-old → swap → drop-backup. Failure at any swap step + // restores the previous output. Internal for tests. + internal static async Task RunWithStagingAsync( + DirectoryInfo outputDir, + Func generate) + { + var stagingDir = new DirectoryInfo( + Path.Combine(outputDir.Parent!.FullName, $"{outputDir.Name}.staging.{Guid.NewGuid():N}")); + DirectoryInfo? backupDir = null; + stagingDir.Create(); + try + { + await generate(stagingDir); + + WriteManagedMarker(stagingDir); + + ValidateOutputDirIsWipeable(outputDir); + + if (outputDir.Exists) + { + backupDir = new DirectoryInfo( + Path.Combine(outputDir.Parent!.FullName, $"{outputDir.Name}.backup.{Guid.NewGuid():N}")); + Directory.Move(outputDir.FullName, backupDir.FullName); + } + + try + { + Directory.Move(stagingDir.FullName, outputDir.FullName); + // Null the local immediately on success so the finally-block + // cleanup can't ever re-target the now-renamed staging dir + // (which IS the user's new output). + stagingDir = null!; + } + catch + { + // Restore the previous output so the user isn't left empty. + if (backupDir is not null && backupDir.Exists) + { + try { Directory.Move(backupDir.FullName, outputDir.FullName); backupDir = null; } + catch (Exception restoreEx) + { + // Restore failed — preserve the backup on disk (it's + // the only surviving copy) and surface the path so + // the user can recover manually. Null the local so + // the finally block doesn't delete it. + var preserved = backupDir!.FullName; + backupDir = null; + throw new IOException( + $"Codegen failed AND the previous output could not be restored. " + + $"Your previous bindings are preserved at: {preserved}. " + + $"Move them back manually if needed. Restore error: {restoreEx.Message}"); + } + } + throw; + } + } + finally + { + if (stagingDir is not null) + { + try { stagingDir.Delete(recursive: true); } + catch { /* orphan staging is harmless */ } + } + if (backupDir is not null) + { + try { backupDir.Delete(recursive: true); } + catch { /* orphan backup is harmless */ } + } + } + } + + // Resolve output dir, refusing escape (typos / reparse points) so the + // pre-codegen wipe stays inside the workspace. + internal static DirectoryInfo ResolveOutputDir(DirectoryInfo workspaceDir, string output) + { + if (string.IsNullOrWhiteSpace(output)) + { + output = "bindings/winrt"; + } + var path = Path.IsPathRooted(output) + ? output + : Path.Combine(workspaceDir.FullName, output); + var resolvedFull = Path.GetFullPath(path); + var workspaceFull = Path.GetFullPath(workspaceDir.FullName) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + // Lexical containment check. + var sep = Path.DirectorySeparatorChar; + var prefix = workspaceFull + sep; + var insideWorkspace = resolvedFull.Length > prefix.Length + && resolvedFull.StartsWith(prefix, StringComparison.OrdinalIgnoreCase); + if (!insideWorkspace) + { + throw new InvalidOperationException( + $"jsBindings.output ('{output}') resolves to '{resolvedFull}' which is outside the workspace " + + $"('{workspaceFull}'). The output directory is wiped before each codegen run, so it must be " + + "a path strictly inside the workspace. Use a relative path like 'bindings/winrt' or an absolute " + + "path that descends from the workspace root."); + } + + // Physical containment: reject reparse points in the chain so the + // recursive delete can't follow a junction outside the workspace. + for (var probe = new DirectoryInfo(resolvedFull); + probe is not null && probe.FullName.Length >= workspaceFull.Length; + probe = probe.Parent) + { + if (probe.Exists && (probe.Attributes & FileAttributes.ReparsePoint) != 0) + { + throw new InvalidOperationException( + $"jsBindings.output ('{output}') resolves through a reparse point at '{probe.FullName}'. " + + "Reparse points (symlinks / junctions) are rejected because they could redirect the output " + + "wipe outside the workspace. Move the output to a regular subdirectory of the workspace."); + } + if (string.Equals(probe.FullName.TrimEnd(sep, Path.AltDirectorySeparatorChar), + workspaceFull, StringComparison.OrdinalIgnoreCase)) + { + break; + } + } + + return new DirectoryInfo(resolvedFull); + } + + // Deduplicated emit-set: packages + user additionalWinmds + optional SDK winmd. + internal static List CollectListedWinmds( + IReadOnlyList winmds, + IReadOnlyList? userAdditional, + FileInfo? windowsSdkWinmd) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var result = new List(); + void Add(FileInfo? f) + { + if (f is null) + { + return; + } + if (seen.Add(f.FullName)) + { + result.Add(f); + } + } + foreach (var w in winmds) + { + Add(w); + } + if (userAdditional is not null) + { + foreach (var w in userAdditional) + { + Add(w); + } + } + Add(windowsSdkWinmd); + return result; + } + + // Deduplicated ref-set. Entries already in listedWinmds are dropped + // (a file in both wins as emit). + internal static List CollectRefWinmds( + IReadOnlyList? userAdditionalRefs, + IReadOnlyList listedWinmds) + { + var result = new List(); + if (userAdditionalRefs is null || userAdditionalRefs.Count == 0) + { + return result; + } + var listedSet = new HashSet(listedWinmds.Select(f => f.FullName), StringComparer.OrdinalIgnoreCase); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var f in userAdditionalRefs) + { + if (listedSet.Contains(f.FullName)) + { + continue; + } + if (seen.Add(f.FullName)) + { + result.Add(f); + } + } + return result; + } + + // Wipe outputDir only when safe (empty, or has the managed marker). + // Refuses if the dir or any top-level child is a reparse point. + internal static void WipeOutputDirSafely(DirectoryInfo outputDir) + { + if (!outputDir.Exists) + { + return; + } + + // Validation throws on any unsafe state. + ValidateOutputDirIsWipeable(outputDir); + + var entries = outputDir.EnumerateFileSystemInfos().ToList(); + foreach (var entry in entries) + { + switch (entry) + { + case DirectoryInfo d: + d.Delete(recursive: true); + break; + case FileInfo f: + f.Delete(); + break; + } + } + } + + // Throws if outputDir cannot be safely wiped. Does NOT mutate anything. + // Used by RunWithStagingAsync so we can validate-then-rename-to-backup + // instead of validate-and-delete-then-rename — the latter would lose old + // bindings if the post-wipe rename failed. + internal static void ValidateOutputDirIsWipeable(DirectoryInfo outputDir) + { + if (!outputDir.Exists) + { + return; + } + + if ((outputDir.Attributes & FileAttributes.ReparsePoint) != 0) + { + throw new InvalidOperationException( + $"Refusing to wipe '{outputDir.FullName}': it is a reparse point (symlink or junction). " + + "The wipe could follow the link and delete files outside the workspace. " + + "Move the output to a regular directory and try again."); + } + + var entries = outputDir.EnumerateFileSystemInfos().ToList(); + if (entries.Count == 0) + { + return; + } + + var marker = Path.Combine(outputDir.FullName, ManagedMarkerFileName); + if (!File.Exists(marker)) + { + throw new InvalidOperationException( + $"Refusing to wipe non-managed output directory '{outputDir.FullName}'. " + + $"This directory contains files but does not have a '{ManagedMarkerFileName}' marker, " + + "which indicates it was created or modified outside winapp. " + + "Move or delete its contents manually if you intended to reuse this path for JS bindings."); + } + + foreach (var entry in entries) + { + if ((entry.Attributes & FileAttributes.ReparsePoint) != 0) + { + throw new InvalidOperationException( + $"Refusing to wipe '{outputDir.FullName}': child entry '{entry.Name}' is a reparse point. " + + "Delete it manually before re-running codegen."); + } + } + } + + // Write the managed marker. Only its existence is checked; the body + // (timestamp) is a debugging aid. + internal static void WriteManagedMarker(DirectoryInfo outputDir) + { + outputDir.Create(); + var markerPath = Path.Combine(outputDir.FullName, ManagedMarkerFileName); + var lines = new[] + { + "# Generated by winapp dynwinrt-codegen integration. Do not edit.", + "# Presence of this file authorises winapp to wipe the directory on the next run.", + $"generated_at: {DateTimeOffset.UtcNow:O}", + "", + }; + File.WriteAllText(markerPath, string.Join('\n', lines), new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + } + + internal static List BuildBulkArgs( + IReadOnlyList prefixArgs, + IReadOnlyList emitWinmds, + DirectoryInfo outputDir, + JsBindingsConfig config, + List refWinmds) + { + var args = new List(prefixArgs) + { + "generate", + "--winmd", string.Join(';', emitWinmds.Select(f => f.FullName)), + "--output", outputDir.FullName, + "--lang", config.Lang, + }; + if (refWinmds.Count > 0) + { + args.Add("--ref"); + args.Add(string.Join(';', refWinmds.Select(f => f.FullName))); + } + if (config.Lang == "py") + { + args.Add("--pyi"); + } + return args; + } + + internal static List BuildExtraTypeArgs( + IReadOnlyList prefixArgs, + IReadOnlyList emitWinmds, + DirectoryInfo outputDir, + JsBindingsConfig config, + List refWinmds, + JsBindingsExtraType extra) + { + var args = new List(prefixArgs) + { + "generate", + }; + // Emit winmds may be empty in the extraTypes-only cherry-pick + // workflow (refs supply metadata for the named types). + if (emitWinmds.Count > 0) + { + args.Add("--winmd"); + args.Add(string.Join(';', emitWinmds.Select(f => f.FullName))); + } + args.AddRange(new[] + { + "--namespace", extra.Namespace, + "--class-name", string.Join(',', extra.Classes), + "--output", outputDir.FullName, + "--lang", config.Lang, + }); + if (refWinmds.Count > 0) + { + args.Add("--ref"); + args.Add(string.Join(';', refWinmds.Select(f => f.FullName))); + } + return args; + } + + private async Task SpawnCodegenAsync( + string executable, + IReadOnlyList args, + DirectoryInfo workspaceDir, + TaskContext taskContext, + CancellationToken cancellationToken) + { + var psi = new ProcessStartInfo + { + FileName = executable, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + WorkingDirectory = workspaceDir.FullName, + }; + foreach (var a in args) + { + psi.ArgumentList.Add(a); + } + + using var p = Process.Start(psi) + ?? throw new InvalidOperationException($"Failed to start codegen process: {executable}"); + + try + { + // Drain stdout+stderr in parallel to avoid pipe-fill deadlock + // (~4KB Windows kernel buffer → serialised reads can hang). + var stdoutTask = p.StandardOutput.ReadToEndAsync(cancellationToken); + var stderrTask = p.StandardError.ReadToEndAsync(cancellationToken); + await Task.WhenAll(stdoutTask, stderrTask); + var stdout = await stdoutTask; + var stderr = await stderrTask; + await p.WaitForExitAsync(cancellationToken); + + if (!string.IsNullOrWhiteSpace(stdout)) + { + taskContext.AddDebugMessage(stdout.TrimEnd()); + } + if (!string.IsNullOrWhiteSpace(stderr)) + { + taskContext.AddDebugMessage(stderr.TrimEnd()); + } + + if (p.ExitCode != 0) + { + logger.LogError("dynwinrt-codegen exited with code {Code}: {Err}", p.ExitCode, stderr); + throw new InvalidOperationException($"dynwinrt-codegen failed (exit {p.ExitCode}). See debug output for details."); + } + } + catch (OperationCanceledException) + { + // Kill the child tree so we don't leak a zombie holding file + // locks on staging. + try + { + if (!p.HasExited) + { + p.Kill(entireProcessTree: true); + try + { + using var killCts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + await p.WaitForExitAsync(killCts.Token); + } + catch (OperationCanceledException) + { + // OS will reap eventually; let cancellation propagate. + } + } + } + catch (Exception killEx) + { + logger.LogDebug(killEx, "Failed to kill cancelled codegen process (pid {Pid})", p.Id); + } + throw; + } + } + + // Walk parent dirs for node_modules/@microsoft/dynwinrt-codegen (Node.js + // bare-specifier resolution). Prefers the pre-built .exe; falls back to + // cli.js via a PATHEXT-resolved `node`. + internal static (string Executable, List PrefixArgs) ResolveCodegenInvocation( + DirectoryInfo workspaceDir, + string? codegenVersionHint = null) + { + var arch = ResolveArchSubdir(); + DirectoryInfo? lastChecked = null; + + // Search workspace ancestry first (user-installed override), then fall + // back to the wrapper's own node_modules near Environment.ProcessPath. + // pnpm / yarn-Berry layouts often place the codegen under the wrapper + // package rather than the workspace. + var roots = new List { workspaceDir }; + var exePath = Environment.ProcessPath; + if (!string.IsNullOrEmpty(exePath)) + { + var exeDir = Path.GetDirectoryName(exePath); + if (!string.IsNullOrEmpty(exeDir)) + { + var d = new DirectoryInfo(exeDir); + if (!string.Equals(d.FullName, workspaceDir.FullName, StringComparison.OrdinalIgnoreCase)) + { + roots.Add(d); + } + } + } + + foreach (var root in roots) + { + for (var probe = root; probe is not null; probe = probe.Parent) + { + var packageDir = Path.Combine(probe.FullName, "node_modules", "@microsoft", "dynwinrt-codegen"); + if (!Directory.Exists(packageDir)) + { + continue; + } + + // Priority 1: pre-built .exe (no Node startup needed). + var directExe = new FileInfo(Path.Combine(packageDir, "bin", arch, "dynwinrt-codegen.exe")); + if (directExe.Exists) + { + return (directExe.FullName, new List()); + } + + // Priority 2: cli.js via node.exe (defensive fallback). + var localCli = new FileInfo(Path.Combine(packageDir, "cli.js")); + if (localCli.Exists) + { + // Reject .bat/.cmd/.ps1 — those go through cmd.exe parsing + // where user-derived args could be misinterpreted. Only + // spawn native executables (node.exe / node.com). + var nodePath = ResolveExecutableOnPath("node", nativeOnly: true) + ?? throw new InvalidOperationException( + $"The codegen at '{localCli.FullName}' requires a native Node.js executable " + + "(node.exe) on PATH. Install Node 18+ (winget install OpenJS.NodeJS) " + + $"or install {CodegenPackageName} so the pre-built .exe is available."); + return (nodePath, new List { localCli.FullName }); + } + + // Partial install (no exe + no cli.js); remember and keep walking. + lastChecked = new DirectoryInfo(packageDir); + } + } + + var hint = lastChecked is not null + ? $"Found {CodegenPackageName} at '{lastChecked.FullName}' but no executable inside " + + $"(expected 'bin/{arch}/dynwinrt-codegen.exe' or 'cli.js'). The npm package may be corrupt; reinstall it.\n\n" + : $"Searched {CodegenPackageName} upward from '{workspaceDir.FullName}' and the wrapper install — no node_modules/@microsoft/dynwinrt-codegen found.\n\n"; + + var versionForHint = codegenVersionHint ?? CodegenPinnedVersionFallback; + throw new InvalidOperationException( + hint + + "To enable JS bindings, install the codegen via one of:\n" + + " • npm/yarn classic/pnpm (default): npm i -D @microsoft/winappcli\n" + + " (bundles " + CodegenPackageName + " as a transitive dependency)\n" + + " • Install the codegen directly: npm i -D " + + CodegenPackageName + "@" + versionForHint + "\n" + + " • yarn berry (PnP): set 'nodeLinker: node-modules' in .yarnrc.yml, then yarn install\n" + + " • pnpm with isolated linker: set 'node-linker=hoisted' in .npmrc, then pnpm install\n\n" + + "See https://github.com/microsoft/WinAppCli#electron--nodejs for setup details."); + } + + // Read the codegen version from the npm wrapper's package.json; falls + // back to the in-source constant when the provider can't locate it + // (dev / test scenarios outside the npm install layout). + private string? TryReadCodegenVersionHint() + { + try + { + return npmWrapperVersionProvider.DynWinrtCodegenVersion; + } + catch + { + return null; + } + } + + // Resolve `command` via PATH + PATHEXT, skipping CWD-equivalent entries + // to prevent local hijack (e.g. node.exe dropped in the workspace). + // When `nativeOnly: true`, only .exe / .com matches are returned — + // .bat / .cmd / .ps1 dispatch through cmd.exe / pwsh and would re-parse + // any user-derived args, so we reject them in security-sensitive paths. + internal static string? ResolveExecutableOnPath(string command, bool nativeOnly = false) + { + if (string.IsNullOrWhiteSpace(command)) + { + return null; + } + + if (Path.IsPathRooted(command) + || command.Contains(Path.DirectorySeparatorChar) + || command.Contains(Path.AltDirectorySeparatorChar)) + { + return File.Exists(command) ? Path.GetFullPath(command) : null; + } + + var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + var pathDirs = pathEnv.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries); + var extEnv = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? (Environment.GetEnvironmentVariable("PATHEXT") ?? ".COM;.EXE;.BAT;.CMD") + : string.Empty; + var exts = extEnv.Split(';', StringSplitOptions.RemoveEmptyEntries); + if (nativeOnly) + { + exts = exts.Where(e => + e.Equals(".exe", StringComparison.OrdinalIgnoreCase) + || e.Equals(".com", StringComparison.OrdinalIgnoreCase)).ToArray(); + } + + string? cwdFull = null; + try + { + cwdFull = Path.GetFullPath(Directory.GetCurrentDirectory()); + } + catch + { + // Best-effort; literal "." / "" skips still apply. + } + + foreach (var dir in pathDirs) + { + var trimmed = dir.Trim().Trim('"'); + if (string.IsNullOrEmpty(trimmed) || trimmed == ".") + { + continue; + } + // Reject relative PATH entries entirely — they would be resolved + // against CWD and let a workspace-local `./bin` shadow trusted + // system locations. + if (!Path.IsPathFullyQualified(trimmed)) + { + continue; + } + if (cwdFull is not null) + { + string? resolved = null; + try + { + resolved = Path.GetFullPath(trimmed); + } + catch + { + continue; + } + if (string.Equals(resolved, cwdFull, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + } + + // Bare match (command may already include an extension). In + // nativeOnly mode, only accept when the existing extension is + // .exe/.com — otherwise the caller would unknowingly spawn a + // .bat/.cmd. + var bare = Path.Combine(trimmed, command); + if (File.Exists(bare)) + { + if (nativeOnly) + { + var bareExt = Path.GetExtension(bare); + if (!bareExt.Equals(".exe", StringComparison.OrdinalIgnoreCase) + && !bareExt.Equals(".com", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + } + return Path.GetFullPath(bare); + } + foreach (var ext in exts) + { + var candidate = Path.Combine(trimmed, command + ext); + if (File.Exists(candidate)) + { + return Path.GetFullPath(candidate); + } + } + } + return null; + } + + // Pick the bin// for the process arch. The codegen npm package + // only ships x64 / arm64; other arches map to x64. + private static string ResolveArchSubdir() => RuntimeInformation.ProcessArchitecture switch + { + Architecture.Arm64 => "arm64", + _ => "x64", + }; +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/IConfigService.cs b/src/winapp-CLI/WinApp.Cli/Services/IConfigService.cs index fd5ba248..eb977d5a 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/IConfigService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/IConfigService.cs @@ -10,5 +10,10 @@ internal interface IConfigService FileInfo ConfigPath { get; set; } bool Exists(); WinappConfig Load(); + + // Full save. Drops comments / unknown fields. void Save(WinappConfig cfg); + + // Splice only the jsBindings: block; preserves rest of yaml. + void SaveJsBindingsOnly(WinappConfig cfg); } diff --git a/src/winapp-CLI/WinApp.Cli/Services/IDynWinrtCodegenService.cs b/src/winapp-CLI/WinApp.Cli/Services/IDynWinrtCodegenService.cs new file mode 100644 index 00000000..3748a4e4 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/IDynWinrtCodegenService.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.ConsoleTasks; +using WinApp.Cli.Models; + +namespace WinApp.Cli.Services; + +// Generates JS / TS / Python WinRT bindings via @microsoft/dynwinrt-codegen. +internal interface IDynWinrtCodegenService +{ + // One bulk pass + one pass per extraTypes entry. + Task RunAsync( + JsBindingsConfig config, + IReadOnlyList winmds, + FileInfo? windowsSdkWinmd, + DirectoryInfo workspaceDir, + DirectoryInfo winappDir, + TaskContext taskContext, + IReadOnlyList? userAdditionalWinmds = null, + IReadOnlyList? userAdditionalRefs = null, + CancellationToken cancellationToken = default); +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/IJsBindingsWorkspaceService.cs b/src/winapp-CLI/WinApp.Cli/Services/IJsBindingsWorkspaceService.cs new file mode 100644 index 00000000..1a91fb78 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/IJsBindingsWorkspaceService.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.ConsoleTasks; +using WinApp.Cli.Models; + +namespace WinApp.Cli.Services; + +// Single owner of the JS-bindings pipeline; used by both init/restore and add. +internal interface IJsBindingsWorkspaceService +{ + // discover → partition → resolve user winmds → codegen → ensure runtime dep. + Task RunAsync( + JsBindingsOrchestrationContext context, + TaskContext taskContext, + CancellationToken cancellationToken = default); + + // Inject @microsoft/dynwinrt into package.json as a production dep, then + // print a package-manager-aware install hint. Called early in init when + // --js-bindings is set so users can `npm install` while codegen runs. + void EnsureRuntimeDependencyAndPrintHint(DirectoryInfo workspaceDirectory); + + // Top-level `node jsbindings add` flow: load winapp.yaml, prompt about + // existing block, splice-save, invoke RunAsync, cleanup old output dir. + // Returns the exit code suitable for the System.CommandLine handler. + Task AddAsync(AddJsBindingsOptions options, CancellationToken cancellationToken = default); + + // Top-level `node jsbindings generate` flow: read existing jsBindings: + // block from winapp.yaml without mutation, then run codegen. Errors if + // no jsBindings: block exists. + Task GenerateAsync(GenerateJsBindingsOptions options, CancellationToken cancellationToken = default); +} + +// Inputs to IJsBindingsWorkspaceService.RunAsync. +internal sealed class JsBindingsOrchestrationContext +{ + public required JsBindingsConfig JsBindingsConfig { get; init; } + public required WinappConfig WinappConfig { get; init; } + public required DirectoryInfo WorkspaceDir { get; init; } + public required DirectoryInfo LocalWinappDir { get; init; } + public required DirectoryInfo NugetCacheDir { get; init; } + + // (name → version) incl. transitive deps. null on the add path — derived + // from lockfile / transitive expansion. + public IReadOnlyDictionary? UsedVersions { get; init; } + + public bool EnsureRuntimeDependency { get; init; } = true; +} + +internal sealed class JsBindingsOrchestrationResult +{ + public required int ExitCode { get; init; } + public required string Message { get; init; } + public DirectoryInfo? OutputDir { get; init; } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/INpmWrapperVersionProvider.cs b/src/winapp-CLI/WinApp.Cli/Services/INpmWrapperVersionProvider.cs new file mode 100644 index 00000000..4b3cdfa4 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/INpmWrapperVersionProvider.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +namespace WinApp.Cli.Services; + +// Pinned version from the @microsoft/winappcli npm wrapper. dynwinrt and +// dynwinrt-codegen ship in lockstep. +public interface INpmWrapperVersionProvider +{ + string DynWinrtVersion { get; } + string DynWinrtCodegenVersion { get; } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/IPackageManagerDetector.cs b/src/winapp-CLI/WinApp.Cli/Services/IPackageManagerDetector.cs new file mode 100644 index 00000000..f80855f6 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/IPackageManagerDetector.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.IO; + +namespace WinApp.Cli.Services; + +// JS package manager detected from a workspace. +public sealed record DetectedPackageManager(string Name, string InstallCommand); + +// Precedence: Corepack `packageManager` field → lockfile → npm. +public interface IPackageManagerDetector +{ + // Never null — falls back to npm. + DetectedPackageManager Detect(DirectoryInfo workspaceDirectory); +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/IUserPackageJsonService.cs b/src/winapp-CLI/WinApp.Cli/Services/IUserPackageJsonService.cs new file mode 100644 index 00000000..f6b458e6 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/IUserPackageJsonService.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.IO; + +namespace WinApp.Cli.Services; + +public enum RuntimeDependencyOutcome +{ + Added, + AlreadyPresent, + PresentInDevDependencies, + NoPackageJson, +} + +// Edits the user's package.json. Bindings need `dependencies` (not dev), +// or `npm ci --omit=dev` strips them. +public interface IUserPackageJsonService +{ + RuntimeDependencyOutcome EnsureRuntimeDependency( + DirectoryInfo workspaceDirectory, + string packageName, + string version); +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/IWinmdsLockfileService.cs b/src/winapp-CLI/WinApp.Cli/Services/IWinmdsLockfileService.cs new file mode 100644 index 00000000..5dfd4dc7 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/IWinmdsLockfileService.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.Models; + +namespace WinApp.Cli.Services; + +// Reads / writes .winapp/winmds.lock.json. Best-effort; callers fall back +// to live discovery on any error. +internal interface IWinmdsLockfileService +{ + // Absolute path of the lockfile under winappDir. + FileInfo GetLockfilePath(DirectoryInfo winappDir); + + // Build a WinmdsLockfile from restore's in-memory state and write to disk. + Task WriteAsync( + DirectoryInfo winappDir, + IReadOnlyDictionary usedVersions, + IReadOnlyList discoveredWinmds, + DirectoryInfo nugetCacheDir, + string? yamlPackagesHash = null, + CancellationToken cancellationToken = default); + + // Read the lockfile if present and schema-compatible; else null. Never throws. + Task TryReadAsync( + DirectoryInfo winappDir, + CancellationToken cancellationToken = default); +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/IWorkspaceSetupService.cs b/src/winapp-CLI/WinApp.Cli/Services/IWorkspaceSetupService.cs index 308a7fb3..6719f26b 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/IWorkspaceSetupService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/IWorkspaceSetupService.cs @@ -7,11 +7,7 @@ namespace WinApp.Cli.Services; internal interface IWorkspaceSetupService { - /// - /// Finds the MSIX directory for Windows App SDK runtime packages - /// - /// Optional dictionary of package versions to look for specific installed packages - /// The path to the MSIX directory, or null if not found + // Finds the MSIX directory for Windows App SDK runtime packages public DirectoryInfo? FindWindowsAppSdkMsixDirectory(Dictionary? usedVersions = null); public Task SetupWorkspaceAsync(WorkspaceSetupOptions options, CancellationToken cancellationToken = default); public Task<(int InstalledCount, int ErrorCount)> InstallWindowsAppRuntimeAsync(DirectoryInfo msixDir, TaskContext taskContext, CancellationToken cancellationToken); diff --git a/src/winapp-CLI/WinApp.Cli/Services/JsBindingsPresets.cs b/src/winapp-CLI/WinApp.Cli/Services/JsBindingsPresets.cs new file mode 100644 index 00000000..4bdeb5ba --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/JsBindingsPresets.cs @@ -0,0 +1,242 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +namespace WinApp.Cli.Services; + +// Codegen role per winmd. +internal enum WinmdPackageCategory +{ + Emit, // --winmd + RefOnly, // --ref + Skip, +} + +// Named WinAppSDK slices selectable via --js-bindings-{preset} or +// `node jsbindings add --{preset}`. Maps to NuGet package IDs. +internal static class JsBindingsPresets +{ + public static readonly IReadOnlyDictionary> KnownPresets = + new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["ai"] = new[] + { + "Microsoft.WindowsAppSDK.AI", + }, + }; + + public static bool TryResolve(string presetName, out IReadOnlyList packageIds) + { + if (KnownPresets.TryGetValue(presetName, out var resolved)) + { + packageIds = resolved; + return true; + } + packageIds = Array.Empty(); + return false; + } + + public static string KnownPresetsDisplay() + { + return string.Join(", ", KnownPresets.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase)); + } + + // Union package IDs of multiple presets; dedup case-insensitively. + public static IReadOnlyList ResolveAndUnion(IEnumerable presetNames) + { + var result = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var name in presetNames) + { + if (string.IsNullOrWhiteSpace(name)) + { + continue; + } + if (!KnownPresets.TryGetValue(name, out var packageIds)) + { + continue; + } + foreach (var p in packageIds) + { + if (seen.Add(p)) + { + result.Add(p); + } + } + } + return result; + } + + // "ai" → "--js-bindings-ai" (init flag) + public static string AliasFlagName(string presetName) => + $"--js-bindings-{presetName.ToLowerInvariant()}"; + + // "ai" → "--ai" (add sub-command flag) + public static string AddAliasFlagName(string presetName) => + $"--{presetName.ToLowerInvariant()}"; + + // Built-in denylists; user `jsBindings` overrides layer on top. + + // RefOnly: own classes are undriveable but other packages reference them. + private static readonly HashSet DefaultRefOnlyPackages = + new(StringComparer.OrdinalIgnoreCase) + { + "Microsoft.WindowsAppSDK.InteractiveExperiences", + }; + + // Skip: dropped entirely. + private static readonly HashSet DefaultSkippedPackages = + new(StringComparer.OrdinalIgnoreCase) + { + "Microsoft.WindowsAppSDK.WinUI", + }; + + // User overrides. Precedence: Emit → Skip → RefOnly → Emit (default). + public sealed class PackageCategoryOverrides + { + public IReadOnlyCollection? Skip { get; init; } + public IReadOnlyCollection? RefOnly { get; init; } + public IReadOnlyCollection? Emit { get; init; } + + public static readonly PackageCategoryOverrides Empty = new(); + + public static PackageCategoryOverrides From(Models.JsBindingsConfig? config) + { + if (config is null) + { + return Empty; + } + return new PackageCategoryOverrides + { + Skip = config.SkipPackages.Count > 0 + ? new HashSet(config.SkipPackages, StringComparer.OrdinalIgnoreCase) + : null, + RefOnly = config.RefOnlyPackages.Count > 0 + ? new HashSet(config.RefOnlyPackages, StringComparer.OrdinalIgnoreCase) + : null, + Emit = config.EmitPackages.Count > 0 + ? new HashSet(config.EmitPackages, StringComparer.OrdinalIgnoreCase) + : null, + }; + } + } + + // Categorize packageId; defaults to Emit. + public static WinmdPackageCategory ClassifyPackage( + string packageId, + PackageCategoryOverrides? overrides = null) + { + if (string.IsNullOrWhiteSpace(packageId)) + { + return WinmdPackageCategory.Emit; + } + var ov = overrides ?? PackageCategoryOverrides.Empty; + + // Force-emit always wins — lets users opt back in to a denylisted package. + if (ov.Emit is not null && ov.Emit.Contains(packageId)) + { + return WinmdPackageCategory.Emit; + } + if (DefaultSkippedPackages.Contains(packageId) || + (ov.Skip is not null && ov.Skip.Contains(packageId))) + { + return WinmdPackageCategory.Skip; + } + if (DefaultRefOnlyPackages.Contains(packageId) || + (ov.RefOnly is not null && ov.RefOnly.Contains(packageId))) + { + return WinmdPackageCategory.RefOnly; + } + return WinmdPackageCategory.Emit; + } + + // Extract package ID from a NuGet-cache winmd path. With `nugetCacheRoot`, + // returns the child segment of the cache root; without it, scans for a + // literal "packages" segment. Returns null when neither applies. + public static string? ExtractPackageIdFromPath(string winmdPath, string? nugetCacheRoot = null) + { + if (string.IsNullOrWhiteSpace(winmdPath)) + { + return null; + } + + if (!string.IsNullOrWhiteSpace(nugetCacheRoot)) + { + try + { + var full = Path.GetFullPath(winmdPath); + var root = Path.GetFullPath(nugetCacheRoot!) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var rootPrefix = root + Path.DirectorySeparatorChar; + if (full.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase)) + { + var rel = full.Substring(rootPrefix.Length); + var firstSep = rel.IndexOfAny(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }); + return firstSep > 0 ? rel.Substring(0, firstSep) : rel; + } + } + catch + { + // Fall through to legacy heuristic. + } + } + + var segs = winmdPath.Split( + new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, + StringSplitOptions.RemoveEmptyEntries); + for (int i = 0; i < segs.Length - 1; i++) + { + if (segs[i].Equals("packages", StringComparison.OrdinalIgnoreCase)) + { + return segs[i + 1]; + } + } + return null; + } + + // Result of partitioning a discovered winmd list by category. + public readonly record struct WinmdPartition( + IReadOnlyList Emit, + IReadOnlyList RefOnly, + IReadOnlyList Skipped); + + // Partition winmds by category. Entries with no extractable package ID + // default to Emit. When `emitScope` is provided, out-of-scope emit + // packages are demoted to RefOnly so codegen still sees their metadata + // for cross-package type resolution. Skip/RefOnly classifications take + // precedence over scope. + public static WinmdPartition PartitionByPackageCategory( + IReadOnlyList winmds, + PackageCategoryOverrides? overrides = null, + string? nugetCacheRoot = null, + IReadOnlyCollection? emitScope = null) + { + HashSet? scope = emitScope is { Count: > 0 } + ? new HashSet(emitScope, StringComparer.OrdinalIgnoreCase) + : null; + + var emit = new List(); + var refOnly = new List(); + var skipped = new List(); + foreach (var w in winmds) + { + var pkg = ExtractPackageIdFromPath(w.FullName, nugetCacheRoot); + var cat = pkg is null ? WinmdPackageCategory.Emit : ClassifyPackage(pkg, overrides); + + if (scope is not null + && cat == WinmdPackageCategory.Emit + && pkg is not null + && !scope.Contains(pkg)) + { + cat = WinmdPackageCategory.RefOnly; + } + + switch (cat) + { + case WinmdPackageCategory.Skip: skipped.Add(w); break; + case WinmdPackageCategory.RefOnly: refOnly.Add(w); break; + default: emit.Add(w); break; + } + } + return new WinmdPartition(emit, refOnly, skipped); + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/JsBindingsWorkspaceService.cs b/src/winapp-CLI/WinApp.Cli/Services/JsBindingsWorkspaceService.cs new file mode 100644 index 00000000..ce0c709c --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/JsBindingsWorkspaceService.cs @@ -0,0 +1,906 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using Spectre.Console; +using System.Text.Json; +using WinApp.Cli.ConsoleTasks; +using WinApp.Cli.Helpers; +using WinApp.Cli.Models; + +namespace WinApp.Cli.Services; + +// Default IJsBindingsWorkspaceService — composes the single-purpose +// services into the end-to-end pipeline described on the interface. +internal sealed class JsBindingsWorkspaceService( + IPackageLayoutService packageLayoutService, + IWinmdsLockfileService winmdsLockfileService, + IDynWinrtCodegenService dynWinrtCodegenService, + INugetService nugetService, + IUserPackageJsonService userPackageJsonService, + INpmWrapperVersionProvider npmWrapperVersionProvider, + IPackageManagerDetector packageManagerDetector, + IConfigService configService, + IStatusService statusService, + IWinappDirectoryService winappDirectoryService, + IAnsiConsole ansiConsole, + ILogger logger) : IJsBindingsWorkspaceService +{ + public async Task RunAsync( + JsBindingsOrchestrationContext context, + TaskContext taskContext, + CancellationToken cancellationToken = default) + { + try + { + // User winmds first — they can satisfy the no-package-winmds case. + var userWinmds = ResolveAdditionalWinmds( + context.JsBindingsConfig.AdditionalWinmds, + context.WorkspaceDir, + taskContext, + fieldName: "additionalWinmds"); + var userRefs = ResolveAdditionalWinmds( + context.JsBindingsConfig.AdditionalRefs, + context.WorkspaceDir, + taskContext, + fieldName: "additionalRefs"); + + // Step 2: discover package winmds. + var (bindingWinmds, packageRefs, skippedCount, usedVersions) = + await DiscoverWinmdsAsync(context, taskContext, cancellationToken); + + // Empty package discovery is OK if the user supplied their own. + if (bindingWinmds is null || packageRefs is null) + { + if (userWinmds.Count == 0) + { + return new JsBindingsOrchestrationResult + { + ExitCode = 1, + Message = "No .winmd files found for JS binding generation. " + + "Likely causes:\n" + + " • Packages aren't restored yet → run [bold]npx winapp restore[/]\n" + + " • Stale [bold].winapp/winmds.lock.json[/] → re-run restore to regenerate\n" + + " • [bold]jsBindings.packages[/] in winapp.yaml lists package IDs that aren't installed", + }; + } + bindingWinmds = new List(); + packageRefs = new List(); + taskContext.AddDebugMessage( + $"{UiSymbols.Note} No package .winmd files in scope; emitting bindings from additionalWinmds: only ({userWinmds.Count} file(s))."); + } + + // Reject empty emit unless this is a valid extraTypes-only flow + // (refs + at least one valid extraType). Otherwise codegen would + // see --winmd "". + var validExtraTypeCount = CountValidExtraTypes(context.JsBindingsConfig.ExtraTypes); + var hasExtraTypesOnlyFlow = + validExtraTypeCount > 0 + && (userRefs.Count > 0 || packageRefs.Count > 0); + if (bindingWinmds.Count + userWinmds.Count == 0 && !hasExtraTypesOnlyFlow) + { + var extraTypesHint = context.JsBindingsConfig.ExtraTypes.Count > 0 + && validExtraTypeCount == 0 + ? "\n • [bold]extraTypes[/] entries are all malformed (missing namespace or classes) — codegen would skip them all" + : "\n • For an extraTypes-only cherry-pick, ensure [bold]additionalRefs[/] (or a refOnly package) is also set"; + return new JsBindingsOrchestrationResult + { + ExitCode = 1, + Message = "No .winmd files left to emit bindings for after applying jsBindings overrides. " + + "Likely causes:\n" + + " • All packages in [bold]jsBindings.packages[/] are categorized as skip/refOnly (check [bold]skipPackages[/] / [bold]refOnlyPackages[/])\n" + + " • [bold]jsBindings.packages[/] doesn't match any restored package — verify package IDs against winapp.yaml\n" + + " • Add at least one emit-set package, or use [bold]additionalWinmds[/] to supply winmds directly" + + extraTypesHint, + }; + } + + if (packageRefs.Count > 0 || skippedCount > 0) + { + taskContext.AddDebugMessage( + $"{UiSymbols.Note} winmd partition: emit={bindingWinmds.Count}, ref-only={packageRefs.Count}, skipped={skippedCount}"); + } + + var combinedRefs = MergeRefWinmds(packageRefs, userRefs); + + // Step 3: codegen (staging-then-swap internally). + taskContext.UpdateSubStatus("Generating bindings"); + var outputDir = await dynWinrtCodegenService.RunAsync( + context.JsBindingsConfig, + bindingWinmds, + windowsSdkWinmd: null, + workspaceDir: context.WorkspaceDir, + winappDir: context.LocalWinappDir, + taskContext: taskContext, + userAdditionalWinmds: userWinmds, + userAdditionalRefs: combinedRefs, + cancellationToken: cancellationToken); + + // (No lockfile write here: the restore step already writes the + // full lockfile before this overlay runs — rewriting it with the + // scoped emit subset would lose packages outside jsBindings.packages.) + + // Ensure @microsoft/dynwinrt is a production dep so generated + // bindings resolve at runtime. + if (context.EnsureRuntimeDependency) + { + EnsureRuntimeDependencyAndPrintHint(context.WorkspaceDir); + } + + return new JsBindingsOrchestrationResult + { + ExitCode = 0, + Message = $"JS bindings generated → [underline]{outputDir.FullName}[/]", + OutputDir = outputDir, + }; + } + catch (OperationCanceledException) + { + return new JsBindingsOrchestrationResult { ExitCode = 1, Message = "JS binding generation cancelled." }; + } + catch (InvalidOperationException ex) + { + // Surface actionable codegen/config errors verbatim (they're + // safe-to-display by contract). + taskContext.AddDebugMessage($"{UiSymbols.Note} JS binding generation failed: {ex.Message}"); + logger.LogDebug(ex, "JS binding generation failed"); + return new JsBindingsOrchestrationResult { ExitCode = 1, Message = ex.Message }; + } + catch (Exception ex) + { + taskContext.AddDebugMessage($"{UiSymbols.Note} JS binding generation failed: {ex.Message}"); + logger.LogDebug(ex, "JS binding generation failed"); + return new JsBindingsOrchestrationResult { ExitCode = 1, Message = "JS binding generation failed." }; + } + } + + // Lockfile fast-path or live discovery. Returns nulls when no winmds found. + private async Task<( + List? BindingWinmds, + List? PackageRefs, + int SkippedCount, + IReadOnlyDictionary? UsedVersions)> + DiscoverWinmdsAsync( + JsBindingsOrchestrationContext context, + TaskContext taskContext, + CancellationToken cancellationToken) + { + // Init/restore already has usedVersions → skip the lockfile fast-path. + if (context.UsedVersions is not null) + { + return await LiveDiscoveryAsync(context.UsedVersions, context, taskContext, cancellationToken); + } + + var lockfile = await winmdsLockfileService.TryReadAsync(context.LocalWinappDir, cancellationToken); + var currentYamlHash = YamlPackagesHasher.Compute(context.WinappConfig.Packages); + if (lockfile is not null + && !string.IsNullOrEmpty(lockfile.YamlPackagesHash) + && !string.Equals(lockfile.YamlPackagesHash, currentYamlHash, StringComparison.Ordinal)) + { + taskContext.AddDebugMessage( + $"{UiSymbols.Note} Winmds lockfile is stale (yaml packages: changed since restore); falling back to live discovery."); + lockfile = null; + } + + if (lockfile is not null) + { + taskContext.AddDebugMessage( + $"{UiSymbols.Note} Using winmds lockfile (generated {lockfile.GeneratedAt}, {lockfile.Packages.Count} packages)"); + var (emit, refOnly, skipped) = PartitionFromLockfile( + lockfile, + context.JsBindingsConfig.Packages, + JsBindingsPresets.PackageCategoryOverrides.From(context.JsBindingsConfig)); + + // Recorded paths still exist? + var missing = emit.Concat(refOnly).Count(f => !f.Exists); + if (missing > 0) + { + taskContext.AddDebugMessage( + $"{UiSymbols.Note} Winmds lockfile references {missing} missing file(s) (NuGet cache cleared?); falling back to live discovery."); + lockfile = null; + } + else if (emit.Count == 0 && refOnly.Count == 0) + { + // Empty after partition — distinct from "no winmds at all". + taskContext.AddDebugMessage( + $"{UiSymbols.Note} Lockfile has no winmds matching the configured packages. Adjust jsBindings.packages or re-run winapp restore."); + return (emit, refOnly, skipped, null); + } + else + { + return (emit, refOnly, skipped, null); + } + } + + // Slow path: cache walk + transitive expansion. + taskContext.AddDebugMessage( + $"{UiSymbols.Note} No usable winmds lockfile; falling back to live discovery (re-run [bold]winapp restore[/] to enable the fast path)."); + + var explicitVersions = context.WinappConfig.Packages.ToDictionary( + p => p.Name, p => p.Version, StringComparer.OrdinalIgnoreCase); + if (explicitVersions.Count == 0) + { + return (null, null, 0, null); + } + + taskContext.UpdateSubStatus("Resolving package graph"); + var derivedUsedVersions = await ExpandTransitiveDependenciesAsync(explicitVersions, taskContext, cancellationToken); + if (derivedUsedVersions.Count > explicitVersions.Count) + { + taskContext.AddDebugMessage( + $"{UiSymbols.Note} Expanded {explicitVersions.Count} pinned package(s) → {derivedUsedVersions.Count} total (with transitive deps)"); + } + return await LiveDiscoveryAsync(derivedUsedVersions, context, taskContext, cancellationToken); + } + + private async Task<( + List? BindingWinmds, + List? PackageRefs, + int SkippedCount, + IReadOnlyDictionary? UsedVersions)> + LiveDiscoveryAsync( + IReadOnlyDictionary usedVersions, + JsBindingsOrchestrationContext context, + TaskContext taskContext, + CancellationToken cancellationToken) + { + await Task.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + taskContext.UpdateSubStatus("Discovering .winmd metadata"); + + // Discover ALL package winmds; the scope narrows EMIT output, not + // codegen's metadata visibility — non-scoped dependencies still flow + // through as RefOnly for cross-package type resolution. + var allPackageWinmds = packageLayoutService.FindWinmds( + context.NugetCacheDir, + new Dictionary(usedVersions, StringComparer.OrdinalIgnoreCase)).ToList(); + if (allPackageWinmds.Count == 0) + { + return (null, null, 0, usedVersions); + } + + var partition = JsBindingsPresets.PartitionByPackageCategory( + allPackageWinmds, + JsBindingsPresets.PackageCategoryOverrides.From(context.JsBindingsConfig), + context.NugetCacheDir.FullName, + emitScope: context.JsBindingsConfig.Packages); + return (partition.Emit.ToList(), partition.RefOnly.ToList(), partition.Skipped.Count, usedVersions); + } + + // ───────────────────────────────────────────────────────────────────── + // Helpers (originally lived in WorkspaceSetupService). + // ───────────────────────────────────────────────────────────────────── + + internal static Dictionary ScopeUsedVersionsToBindingPackages( + Dictionary usedVersions, + IReadOnlyList? bindingPackages) + { + if (bindingPackages is null || bindingPackages.Count == 0) + { + return usedVersions; + } + var allow = new HashSet(bindingPackages, StringComparer.OrdinalIgnoreCase); + var filtered = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (pkg, ver) in usedVersions) + { + if (allow.Contains(pkg)) + { + filtered[pkg] = ver; + } + } + return filtered; + } + + internal static (List Emit, List RefOnly, int SkippedCount) PartitionFromLockfile( + WinmdsLockfile lockfile, + IReadOnlyList? scopePackages, + JsBindingsPresets.PackageCategoryOverrides? overrides = null) + { + HashSet? scope = null; + if (scopePackages is { Count: > 0 }) + { + scope = new HashSet(scopePackages, StringComparer.OrdinalIgnoreCase); + } + + var emit = new List(); + var refOnly = new List(); + var skippedCount = 0; + foreach (var pkg in lockfile.Packages) + { + // Scope is applied AFTER classification — unscoped emit packages + // are demoted to RefOnly so codegen still sees them for type + // resolution. Skip/RefOnly classifications are scope-independent. + var cat = JsBindingsPresets.ClassifyPackage(pkg.Name, overrides); + if (scope is not null + && cat == WinmdPackageCategory.Emit + && !scope.Contains(pkg.Name)) + { + cat = WinmdPackageCategory.RefOnly; + } + + switch (cat) + { + case WinmdPackageCategory.Skip: + skippedCount += pkg.Winmds.Count > 0 ? 1 : 0; + break; + case WinmdPackageCategory.RefOnly: + foreach (var path in pkg.Winmds) + { + // Drop UNC paths so a tampered lockfile can't + // trigger credential-leaking SMB probes downstream. + if (IsNetworkPath(path)) + { + continue; + } + refOnly.Add(new FileInfo(path)); + } + break; + default: + foreach (var path in pkg.Winmds) + { + if (IsNetworkPath(path)) + { + continue; + } + emit.Add(new FileInfo(path)); + } + break; + } + } + return (emit, refOnly, skippedCount); + } + + internal static List MergeRefWinmds( + IReadOnlyList first, + IReadOnlyList? second) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var result = new List(); + foreach (var f in first) + { + if (seen.Add(f.FullName)) + { + result.Add(f); + } + } + if (second is not null) + { + foreach (var f in second) + { + if (seen.Add(f.FullName)) + { + result.Add(f); + } + } + } + return result; + } + + internal async Task> ExpandTransitiveDependenciesAsync( + Dictionary usedVersions, + TaskContext taskContext, + CancellationToken cancellationToken) + { + var expanded = new Dictionary(usedVersions, StringComparer.OrdinalIgnoreCase); + var roots = usedVersions.ToList(); + foreach (var (name, version) in roots) + { + try + { + var deps = await nugetService.GetPackageDependenciesAsync(name, version, cancellationToken); + foreach (var (depId, depVersionSpec) in deps) + { + var depVersion = NugetService.ParseMinimumVersion(depVersionSpec); + if (string.IsNullOrEmpty(depVersion)) + { + continue; + } + if (!expanded.ContainsKey(depId)) + { + expanded[depId] = depVersion; + } + } + } + catch (Exception ex) + { + taskContext.AddDebugMessage( + $"{UiSymbols.Note} Could not expand transitive deps for {name} {version}: {ex.Message}"); + logger.LogDebug(ex, + "Transitive dependency expansion failed for {PackageName} {Version}", name, version); + } + } + return expanded; + } + + private List ResolveAdditionalWinmds( + List entries, + DirectoryInfo workspaceDir, + TaskContext taskContext, + string fieldName) + { + var resolved = new List(); + if (entries is null || entries.Count == 0) + { + return resolved; + } + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var entry in entries) + { + if (string.IsNullOrWhiteSpace(entry)) + { + continue; + } + var trimmed = entry.Trim(); + + // Reject UNC / network paths before any probe — FileInfo.Exists + // on a UNC triggers SMB negotiation and would leak the user's + // NTLM hash to the remote host. + if (IsNetworkPath(trimmed)) + { + taskContext.AddDebugMessage( + $"{UiSymbols.Warning} {fieldName} entry rejected as network/UNC path (refusing to probe): {entry}"); + logger.LogWarning( + "{UISymbol} jsBindings.{FieldName} entry refused — network/UNC paths are not allowed (would probe attacker-controlled host on FileInfo.Exists). Entry: {Entry}", + UiSymbols.Warning, + fieldName, + entry); + continue; + } + + var fullPath = Path.IsPathFullyQualified(trimmed) + ? Path.GetFullPath(trimmed) + : Path.GetFullPath(Path.Combine(workspaceDir.FullName, trimmed)); + + // Second guard: after Path.GetFullPath the resolved form might + // still be a UNC (e.g. workspaceDir itself on a network share + // joined with a relative `..\\..\\share\\evil`). + if (IsNetworkPath(fullPath)) + { + taskContext.AddDebugMessage( + $"{UiSymbols.Warning} {fieldName} entry resolved to a network/UNC path, rejected: {entry} → {fullPath}"); + logger.LogWarning( + "{UISymbol} jsBindings.{FieldName} entry resolved to UNC path; refusing to probe. Entry: {Entry} → {FullPath}", + UiSymbols.Warning, + fieldName, + entry, + fullPath); + continue; + } + + if (!seen.Add(fullPath)) + { + continue; + } + + var fi = new FileInfo(fullPath); + if (!fi.Exists) + { + taskContext.AddDebugMessage( + $"{UiSymbols.Note} {fieldName} entry not found, skipping: {entry}"); + logger.LogWarning( + "{UISymbol} jsBindings.{FieldName} entry not found, skipping: {Entry} (resolved to {FullPath})", + UiSymbols.Note, + fieldName, + entry, + fullPath); + continue; + } + + resolved.Add(fi); + } + + return resolved; + } + + // Count extraTypes entries codegen would actually process; entries with + // a blank namespace or no classes are silently skipped. + internal static int CountValidExtraTypes(IReadOnlyList extraTypes) + { + if (extraTypes is null) + { + return 0; + } + var count = 0; + foreach (var et in extraTypes) + { + if (!string.IsNullOrWhiteSpace(et.Namespace) && et.Classes.Count > 0) + { + count++; + } + } + return count; + } + + // True if `path` looks like a UNC / network location (plain `\\server\share`, + // long-path UNC `\\?\UNC\…`, or device UNC `\\.\UNC\…`). Local DOS device + // paths (`\\?\C:\…`) are NOT classified as network. Used to refuse probing + // attacker-controlled paths via FileInfo.Exists. + internal static bool IsNetworkPath(string path) + { + if (string.IsNullOrEmpty(path)) + { + return false; + } + + // Normalize separators to '\' for the prefix tests. + var p = path.Replace('/', '\\'); + + // Plain UNC: \\server\share… (server is non-empty, not a device + // designator like '?' or '.'). + if (p.Length >= 3 + && p[0] == '\\' && p[1] == '\\' + && p[2] != '?' && p[2] != '.') + { + return true; + } + + // Device-prefixed UNC: \\?\UNC\server\… or \\.\UNC\server\… + if (p.Length >= 8 + && p[0] == '\\' && p[1] == '\\' + && (p[2] == '?' || p[2] == '.') + && p[3] == '\\' + && (p[4] == 'U' || p[4] == 'u') + && (p[5] == 'N' || p[5] == 'n') + && (p[6] == 'C' || p[6] == 'c') + && p[7] == '\\') + { + return true; + } + + return false; + } + + public void EnsureRuntimeDependencyAndPrintHint(DirectoryInfo workspaceDirectory) + { + const string DynWinrtPackageName = "@microsoft/dynwinrt"; + + string version; + try + { + version = npmWrapperVersionProvider.DynWinrtVersion; + } + catch (InvalidOperationException ex) + { + logger.LogWarning( + "{UISymbol} Could not resolve pinned {Package} version: {Reason}", + UiSymbols.Note, DynWinrtPackageName, ex.Message); + return; + } + + RuntimeDependencyOutcome outcome; + try + { + outcome = userPackageJsonService.EnsureRuntimeDependency( + workspaceDirectory, DynWinrtPackageName, version); + } + catch (InvalidOperationException ex) + { + logger.LogWarning( + "{UISymbol} Could not update package.json for {Package}: {Reason}. " + + "Add it manually to your dependencies.", + UiSymbols.Note, DynWinrtPackageName, ex.Message); + return; + } + + switch (outcome) + { + case RuntimeDependencyOutcome.Added: + var pmAdded = packageManagerDetector.Detect(workspaceDirectory); + // Info-level so --quiet suppresses; user runs the printed install cmd next. + logger.LogInformation( + "{UISymbol} Added {Package} @ {Version} to your package.json dependencies. Run `{InstallCmd}` to materialize it.", + UiSymbols.Check, DynWinrtPackageName, version, pmAdded.InstallCommand); + break; + case RuntimeDependencyOutcome.PresentInDevDependencies: + // Warning: production deploys (npm ci --omit=dev) will break. + logger.LogWarning( + "{UISymbol} {Package} is in devDependencies — generated bindings need it as a production dep. Move it manually.", + UiSymbols.Note, DynWinrtPackageName); + break; + case RuntimeDependencyOutcome.NoPackageJson: + logger.LogWarning( + "{UISymbol} No package.json found in workspace. Generated bindings will fail to resolve {Package} at runtime. Run `npm init -y` first.", + UiSymbols.Warning, DynWinrtPackageName); + break; + case RuntimeDependencyOutcome.AlreadyPresent: + default: + logger.LogInformation( + "{UISymbol} {Package} already declared in package.json dependencies — leaving it alone.", + UiSymbols.Check, DynWinrtPackageName); + break; + } + } + + public async Task AddAsync(AddJsBindingsOptions options, CancellationToken cancellationToken = default) + { + configService.ConfigPath = new FileInfo(Path.Combine(options.ConfigDir.FullName, "winapp.yaml")); + + if (!configService.Exists()) + { + logger.LogError( + "{UISymbol} winapp.yaml not found at {ConfigPath}. Run 'npx winapp init' first to bootstrap a workspace.", + UiSymbols.Error, + configService.ConfigPath.FullName); + return 1; + } + + WinappConfig config; + try + { + config = configService.Load(); + } + catch (Exception ex) + { + logger.LogError(ex, + "{UISymbol} Failed to parse winapp.yaml at {ConfigPath}: {Message}", + UiSymbols.Error, + configService.ConfigPath.FullName, + ex.Message); + return 1; + } + + // Existing jsBindings? --force replaces; --use-defaults preserves + // (idempotent no-op); interactive prompts; non-interactive errors. + if (config.JsBindings is not null && !options.Force) + { + if (options.UseDefaults) + { + logger.LogInformation( + "{UISymbol} jsBindings already declared; preserving (use --force to patch).", + UiSymbols.Note); + return 0; + } + + bool overwrite; + try + { + overwrite = await ShowConfirmationPromptAsync( + "winapp.yaml already declares jsBindings. Overwrite?", + cancellationToken); + } + catch (OperationCanceledException) + { + throw; // Real user/parent cancellation — let it propagate. + } + catch (Exception) + { + logger.LogError( + "{UISymbol} winapp.yaml already declares jsBindings. Re-run with --force to patch (output and preset packages get overwritten; all other fields preserved). Pass --use-defaults to preserve and exit 0 instead.", + UiSymbols.Error); + return 1; + } + + if (!overwrite) + { + logger.LogInformation("{UISymbol} No changes; existing jsBindings preserved.", UiSymbols.Note); + return 0; + } + } + + // Build the (new or patched) jsBindings block. On --force we patch + // in place — only CLI-supplied fields (output, preset packages) + // overwrite; everything else survives. + var oldOutput = config.JsBindings?.Output; + var newJs = config.JsBindings ?? new JsBindingsConfig(); + if (!string.IsNullOrWhiteSpace(options.Output)) + { + newJs.Output = options.Output!.Trim(); + } + if (options.Presets is { Count: > 0 } presetNames) + { + var packageIds = JsBindingsPresets.ResolveAndUnion(presetNames); + if (packageIds.Count > 0) + { + newJs.Packages = new List(packageIds); + logger.LogDebug( + "{UISymbol} jsBindings presets [{Presets}] → packages=[{Packages}]", + UiSymbols.New, + string.Join(", ", presetNames), + string.Join(", ", packageIds)); + } + } + + // Validate the resolved output path BEFORE we touch yaml. + try + { + DynWinrtCodegenService.ResolveOutputDir(options.BaseDirectory, newJs.Output); + } + catch (InvalidOperationException ex) + { + logger.LogError("{UISymbol} Invalid jsBindings.output: {Reason}", UiSymbols.Error, ex.Message); + return 1; + } + + config.JsBindings = newJs; + + try + { + configService.SaveJsBindingsOnly(config); + } + catch (Exception ex) + { + logger.LogError(ex, + "{UISymbol} Failed to write winapp.yaml at {ConfigPath}: {Message}", + UiSymbols.Error, + configService.ConfigPath.FullName, + ex.Message); + return 1; + } + + logger.LogInformation("{UISymbol} Updated winapp.yaml with jsBindings block", UiSymbols.Save); + + var codegenExit = await statusService.ExecuteWithStatusAsync( + "Generating JS bindings", + async (taskContext, ct) => + { + var nugetCacheDir = nugetService.GetNuGetGlobalPackagesDir(); + var localWinappDir = winappDirectoryService.GetLocalWinappDirectory(options.BaseDirectory); + + var orchResult = await RunAsync( + new JsBindingsOrchestrationContext + { + JsBindingsConfig = config.JsBindings!, + WinappConfig = config, + WorkspaceDir = options.BaseDirectory, + LocalWinappDir = localWinappDir, + NugetCacheDir = nugetCacheDir, + UsedVersions = null, + }, + taskContext, + ct); + return (orchResult.ExitCode, orchResult.Message); + }, + cancellationToken); + + // Cleanup only after codegen succeeds. + if (codegenExit == 0 + && !string.IsNullOrEmpty(oldOutput) + && !string.Equals(oldOutput, newJs.Output, StringComparison.OrdinalIgnoreCase)) + { + try + { + var oldDir = DynWinrtCodegenService.ResolveOutputDir(options.BaseDirectory, oldOutput); + var newDir = DynWinrtCodegenService.ResolveOutputDir(options.BaseDirectory, newJs.Output); + if (oldDir.Exists + && !string.Equals(oldDir.FullName, newDir.FullName, StringComparison.OrdinalIgnoreCase) + && !IsNestedPath(oldDir.FullName, newDir.FullName) + && !IsNestedPath(newDir.FullName, oldDir.FullName)) + { + DynWinrtCodegenService.WipeOutputDirSafely(oldDir); + oldDir.Refresh(); + if (oldDir.Exists && !oldDir.EnumerateFileSystemInfos().Any()) + { + oldDir.Delete(); + } + logger.LogInformation( + "{UISymbol} Removed previous bindings dir {OldDir} (output: changed)", + UiSymbols.Trash, oldDir.FullName); + } + else if (oldDir.Exists + && (IsNestedPath(oldDir.FullName, newDir.FullName) || IsNestedPath(newDir.FullName, oldDir.FullName))) + { + // Nested paths — wiping old would wipe new (or vice versa). + // Skip cleanup so we never delete the bindings we just generated. + logger.LogInformation( + "{UISymbol} Previous bindings dir {OldDir} overlaps new output {NewDir}; skipping cleanup. Delete manually if no longer needed.", + UiSymbols.Note, oldDir.FullName, newDir.FullName); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidOperationException ex) + { + logger.LogInformation( + "{UISymbol} Previous bindings dir not removed: {Reason}. Delete manually if no longer needed.", + UiSymbols.Note, ex.Message); + } + catch (Exception ex) + { + logger.LogWarning(ex, + "{UISymbol} Old output dir cleanup failed: {Reason}.", + UiSymbols.Warning, ex.Message); + } + } + + return codegenExit; + } + + public async Task GenerateAsync(GenerateJsBindingsOptions options, CancellationToken cancellationToken = default) + { + configService.ConfigPath = new FileInfo(Path.Combine(options.ConfigDir.FullName, "winapp.yaml")); + + if (!configService.Exists()) + { + logger.LogError( + "{UISymbol} winapp.yaml not found at {ConfigPath}. Run 'npx winapp init' first to bootstrap a workspace.", + UiSymbols.Error, + configService.ConfigPath.FullName); + return 1; + } + + WinappConfig config; + try + { + config = configService.Load(); + } + catch (Exception ex) + { + logger.LogError(ex, + "{UISymbol} Failed to parse winapp.yaml at {ConfigPath}: {Message}", + UiSymbols.Error, + configService.ConfigPath.FullName, + ex.Message); + return 1; + } + + if (config.JsBindings is null) + { + logger.LogError( + "{UISymbol} No jsBindings: block in winapp.yaml. Run 'npx winapp node jsbindings add' first to declare one.", + UiSymbols.Error); + return 1; + } + + try + { + DynWinrtCodegenService.ResolveOutputDir(options.BaseDirectory, config.JsBindings.Output); + } + catch (InvalidOperationException ex) + { + logger.LogError("{UISymbol} Invalid jsBindings.output: {Reason}", UiSymbols.Error, ex.Message); + return 1; + } + + var codegenExit = await statusService.ExecuteWithStatusAsync( + "Generating JS bindings", + async (taskContext, ct) => + { + var nugetCacheDir = nugetService.GetNuGetGlobalPackagesDir(); + var localWinappDir = winappDirectoryService.GetLocalWinappDirectory(options.BaseDirectory); + + var orchResult = await RunAsync( + new JsBindingsOrchestrationContext + { + JsBindingsConfig = config.JsBindings!, + WinappConfig = config, + WorkspaceDir = options.BaseDirectory, + LocalWinappDir = localWinappDir, + NugetCacheDir = nugetCacheDir, + UsedVersions = null, + }, + taskContext, + ct); + return (orchResult.ExitCode, orchResult.Message); + }, + cancellationToken); + + return codegenExit; + } + + private async Task ShowConfirmationPromptAsync(string prompt, CancellationToken cancellationToken) + { + var result = await ansiConsole.PromptAsync(new ConfirmationPrompt(prompt), cancellationToken); + ansiConsole.Cursor.MoveUp(); + ansiConsole.Write("\x1b[2K"); + ansiConsole.MarkupLine($"{prompt}: [underline]{(result ? "Yes" : "No")}[/]"); + return result; + } + + // True when `child` is at or below `parent` in the file system tree + // (case-insensitive on Windows). Used to skip cleanup of nested + // old/new output dirs where wiping one would wipe the other. + private static bool IsNestedPath(string parent, string child) + { + var p = parent.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var c = child.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (string.Equals(p, c, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + var prefix = p + Path.DirectorySeparatorChar; + return c.StartsWith(prefix, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/NpmWrapperVersionProvider.cs b/src/winapp-CLI/WinApp.Cli/Services/NpmWrapperVersionProvider.cs new file mode 100644 index 00000000..b2b97ccf --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/NpmWrapperVersionProvider.cs @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.IO; +using System.Text.Json; + +namespace WinApp.Cli.Services; + +// Walks up from winapp.exe to the wrapper's package.json (name = +// "@microsoft/winappcli") and reads the dynwinrt-codegen dep pin. Only +// dynwinrt-codegen is in dependencies; dynwinrt shares the same pin. +internal sealed class NpmWrapperVersionProvider : INpmWrapperVersionProvider +{ + private const string WrapperPackageName = "@microsoft/winappcli"; + private const string DynWinrtCodegenPackageName = "@microsoft/dynwinrt-codegen"; + + private readonly Lazy _version; + + public NpmWrapperVersionProvider() + { + _version = new Lazy(Locate); + } + + public string DynWinrtVersion => _version.Value; + + public string DynWinrtCodegenVersion => _version.Value; + + private static string Locate() + { + var exePath = Environment.ProcessPath; + if (string.IsNullOrEmpty(exePath)) + { + throw new InvalidOperationException( + "Environment.ProcessPath is empty. Cannot locate the @microsoft/winappcli npm wrapper. " + + "If you reached this from a test or `dotnet run`, register a stub " + + "INpmWrapperVersionProvider in DI."); + } + + return LocateFrom(Path.GetDirectoryName(exePath)!); + } + + // Internal seam so tests can drive a synthetic layout without + // shelling through Environment.ProcessPath. + internal static string LocateFrom(string startDirectory) + { + var dir = new DirectoryInfo(startDirectory); + while (dir != null) + { + var candidate = Path.Combine(dir.FullName, "package.json"); + if (File.Exists(candidate)) + { + if (TryReadVersion(candidate, out var version)) + { + return version; + } + } + + dir = dir.Parent; + } + + throw new InvalidOperationException( + $"Could not locate the {WrapperPackageName} package.json near {startDirectory}. " + + $"This typically means winapp.exe is running outside its npm install layout " + + $"(e.g. `dotnet run` during local development). Register a stub " + + $"INpmWrapperVersionProvider in DI for that scenario."); + } + + private static bool TryReadVersion(string packageJsonPath, out string version) + { + version = string.Empty; + try + { + using var stream = File.OpenRead(packageJsonPath); + using var doc = JsonDocument.Parse(stream); + var root = doc.RootElement; + + if (!root.TryGetProperty("name", out var nameProp) || + nameProp.ValueKind != JsonValueKind.String || + !string.Equals(nameProp.GetString(), WrapperPackageName, StringComparison.Ordinal)) + { + return false; + } + + if (!root.TryGetProperty("dependencies", out var deps) || + deps.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException( + $"{packageJsonPath} is the {WrapperPackageName} package.json but has no 'dependencies' object."); + } + + version = ReadDep(deps, DynWinrtCodegenPackageName, packageJsonPath); + return true; + } + catch (JsonException ex) + { + throw new InvalidOperationException( + $"Failed to parse {packageJsonPath}: {ex.Message}", ex); + } + } + + private static string ReadDep(JsonElement deps, string packageName, string packageJsonPath) + { + if (!deps.TryGetProperty(packageName, out var prop) || + prop.ValueKind != JsonValueKind.String) + { + throw new InvalidOperationException( + $"{packageJsonPath} is missing '{packageName}' in its 'dependencies'. " + + $"This indicates a build issue with the @microsoft/winappcli npm package."); + } + + var version = prop.GetString(); + if (string.IsNullOrWhiteSpace(version)) + { + throw new InvalidOperationException( + $"{packageJsonPath} declares '{packageName}' with an empty version."); + } + + return version; + } +} + diff --git a/src/winapp-CLI/WinApp.Cli/Services/PackageManagerDetector.cs b/src/winapp-CLI/WinApp.Cli/Services/PackageManagerDetector.cs new file mode 100644 index 00000000..df16f81a --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/PackageManagerDetector.cs @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.IO; +using System.Text.Json; + +namespace WinApp.Cli.Services; + +internal sealed class PackageManagerDetector : IPackageManagerDetector +{ + public DetectedPackageManager Detect(DirectoryInfo workspaceDirectory) + { + ArgumentNullException.ThrowIfNull(workspaceDirectory); + + // Priority 1: Corepack `packageManager` field (e.g. "pnpm@9.2.0"). + var packageJson = Path.Combine(workspaceDirectory.FullName, "package.json"); + if (File.Exists(packageJson)) + { + var fromCorepack = TryReadCorepackField(packageJson); + if (fromCorepack != null) + { + return fromCorepack; + } + } + + // Priority 2: lockfile sniffing. pnpm/yarn/bun first because + // package-lock.json is sometimes auto-created by tools in non-npm + // workspaces. + if (File.Exists(Path.Combine(workspaceDirectory.FullName, "pnpm-lock.yaml"))) + { + return new DetectedPackageManager("pnpm", "pnpm install"); + } + + if (File.Exists(Path.Combine(workspaceDirectory.FullName, "yarn.lock"))) + { + return new DetectedPackageManager("yarn", "yarn install"); + } + + if (File.Exists(Path.Combine(workspaceDirectory.FullName, "bun.lockb")) || + File.Exists(Path.Combine(workspaceDirectory.FullName, "bun.lock"))) + { + return new DetectedPackageManager("bun", "bun install"); + } + + if (File.Exists(Path.Combine(workspaceDirectory.FullName, "package-lock.json")) || + File.Exists(Path.Combine(workspaceDirectory.FullName, "npm-shrinkwrap.json"))) + { + return new DetectedPackageManager("npm", "npm install"); + } + + // Fallback. + return new DetectedPackageManager("npm", "npm install"); + } + + private static DetectedPackageManager? TryReadCorepackField(string packageJsonPath) + { + try + { + using var stream = File.OpenRead(packageJsonPath); + using var doc = JsonDocument.Parse(stream); + if (!doc.RootElement.TryGetProperty("packageManager", out var prop) || + prop.ValueKind != JsonValueKind.String) + { + return null; + } + + var raw = prop.GetString(); + if (string.IsNullOrWhiteSpace(raw)) + { + return null; + } + + // Format: "@" with optional "+sha" suffix. + var atIndex = raw.IndexOf('@'); + var name = atIndex >= 0 ? raw[..atIndex] : raw; + return name.Trim().ToLowerInvariant() switch + { + "npm" => new DetectedPackageManager("npm", "npm install"), + "yarn" => new DetectedPackageManager("yarn", "yarn install"), + "pnpm" => new DetectedPackageManager("pnpm", "pnpm install"), + "bun" => new DetectedPackageManager("bun", "bun install"), + _ => null, // Unknown PM declaration; fall through to lockfile sniffing. + }; + } + catch (JsonException) + { + return null; + } + catch (IOException) + { + return null; + } + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/UserPackageJsonService.cs b/src/winapp-CLI/WinApp.Cli/Services/UserPackageJsonService.cs new file mode 100644 index 00000000..2689a9ff --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/UserPackageJsonService.cs @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace WinApp.Cli.Services; + +internal sealed class UserPackageJsonService : IUserPackageJsonService +{ + public RuntimeDependencyOutcome EnsureRuntimeDependency( + DirectoryInfo workspaceDirectory, + string packageName, + string version) + { + ArgumentNullException.ThrowIfNull(workspaceDirectory); + ArgumentException.ThrowIfNullOrWhiteSpace(packageName); + ArgumentException.ThrowIfNullOrWhiteSpace(version); + + var packageJsonPath = Path.Combine(workspaceDirectory.FullName, "package.json"); + if (!File.Exists(packageJsonPath)) + { + return RuntimeDependencyOutcome.NoPackageJson; + } + + // JsonNode preserves unrelated keys exactly; JsonSerializer would + // re-shape the whole file. + JsonNode? root; + try + { + using var stream = File.OpenRead(packageJsonPath); + root = JsonNode.Parse(stream); + } + catch (JsonException ex) + { + throw new InvalidOperationException( + $"Failed to parse {packageJsonPath}: {ex.Message}", ex); + } + + if (root is not JsonObject obj) + { + throw new InvalidOperationException( + $"{packageJsonPath} root is not a JSON object."); + } + + if (obj["dependencies"] is JsonObject deps && deps[packageName] != null) + { + return RuntimeDependencyOutcome.AlreadyPresent; + } + + // Don't auto-promote dev→dep; the user pinned it under dev for a reason. + if (obj["devDependencies"] is JsonObject devDeps && devDeps[packageName] != null) + { + return RuntimeDependencyOutcome.PresentInDevDependencies; + } + + if (obj["dependencies"] is not JsonObject deps2) + { + deps2 = new JsonObject(); + // Insert "dependencies" right after "version" (conventional layout). + obj = ReinsertWithDependencies(obj, deps2); + root = obj; + } + deps2[packageName] = JsonValue.Create(version); + + // 2-space indent matches npm/yarn/pnpm; relaxed escaping keeps '/' readable. + var options = new JsonSerializerOptions + { + WriteIndented = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + var serialized = root.ToJsonString(options); + + // Preserve trailing newline if the original had one. + var original = File.ReadAllText(packageJsonPath); + if (original.EndsWith('\n') && !serialized.EndsWith('\n')) + { + serialized += '\n'; + } + + File.WriteAllText(packageJsonPath, serialized); + return RuntimeDependencyOutcome.Added; + } + + // Rebuild `original` with `newDependencies` slotted right after "version" + // (or appended). JsonNode children can only have one parent, so we detach + // and re-parent. + private static JsonObject ReinsertWithDependencies(JsonObject original, JsonObject newDependencies) + { + var rebuilt = new JsonObject(); + bool inserted = false; + + var entries = original.ToList(); + foreach (var kvp in entries) + { + original.Remove(kvp.Key); + } + + foreach (var (key, value) in entries) + { + rebuilt[key] = value; + if (!inserted && string.Equals(key, "version", StringComparison.Ordinal)) + { + rebuilt["dependencies"] = newDependencies; + inserted = true; + } + } + + if (!inserted) + { + rebuilt["dependencies"] = newDependencies; + } + + return rebuilt; + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/WinmdsLockfileService.cs b/src/winapp-CLI/WinApp.Cli/Services/WinmdsLockfileService.cs new file mode 100644 index 00000000..67c26028 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/WinmdsLockfileService.cs @@ -0,0 +1,171 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using WinApp.Cli.Models; + +namespace WinApp.Cli.Services; + +// UTF-8 (no BOM) indented JSON, LF endings. Atomic writes via tmp + File.Move. +internal sealed class WinmdsLockfileService(ILogger logger) : IWinmdsLockfileService +{ + public const string LockfileName = "winmds.lock.json"; + + public FileInfo GetLockfilePath(DirectoryInfo winappDir) => + new(Path.Combine(winappDir.FullName, LockfileName)); + + public async Task WriteAsync( + DirectoryInfo winappDir, + IReadOnlyDictionary usedVersions, + IReadOnlyList discoveredWinmds, + DirectoryInfo nugetCacheDir, + string? yamlPackagesHash = null, + CancellationToken cancellationToken = default) + { + string? tempPath = null; + try + { + winappDir.Create(); + var lockfile = BuildLockfile(usedVersions, discoveredWinmds, nugetCacheDir, yamlPackagesHash); + var path = GetLockfilePath(winappDir); + + // Atomic write via tmp + rename; guid suffix avoids concurrent + // writers colliding on staging. + tempPath = $"{path.FullName}.tmp.{Guid.NewGuid():N}"; + var json = JsonSerializer.Serialize(lockfile, WinmdsLockfileJsonContext.Default.WinmdsLockfile); + await File.WriteAllTextAsync( + tempPath, + json + "\n", + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), + cancellationToken); + File.Move(tempPath, path.FullName, overwrite: true); + tempPath = null; + + logger.LogDebug( + "Wrote winmds lockfile ({PackageCount} packages, {WinmdCount} winmds) → {LockfilePath}", + lockfile.Packages.Count, discoveredWinmds.Count, path.FullName); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Lockfile is an optimization, not a correctness requirement. + logger.LogDebug(ex, "Failed to write winmds lockfile (continuing without)"); + } + finally + { + // Clean up staging if Move never ran. + if (tempPath is not null) + { + try { File.Delete(tempPath); } + catch { /* ignore — leaked tmp file is harmless */ } + } + } + } + + public async Task TryReadAsync( + DirectoryInfo winappDir, + CancellationToken cancellationToken = default) + { + var path = GetLockfilePath(winappDir); + if (!path.Exists) + { + return null; + } + + try + { + await using var stream = File.OpenRead(path.FullName); + var lockfile = await JsonSerializer.DeserializeAsync( + stream, + WinmdsLockfileJsonContext.Default.WinmdsLockfile, + cancellationToken); + + if (lockfile is null) + { + logger.LogDebug("Winmds lockfile {LockfilePath} deserialized to null; ignoring", path.FullName); + return null; + } + + if (lockfile.Schema != WinmdsLockfile.CurrentSchema) + { + logger.LogDebug( + "Winmds lockfile {LockfilePath} schema mismatch (got {Got}, expected {Expected}); ignoring", + path.FullName, lockfile.Schema, WinmdsLockfile.CurrentSchema); + return null; + } + + return lockfile; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + logger.LogDebug(ex, "Failed to read winmds lockfile {LockfilePath}; falling back to live discovery", path.FullName); + return null; + } + } + + // Bucket winmds by package, classify. Paths off the NuGet cache layout + // are dropped. + internal static WinmdsLockfile BuildLockfile( + IReadOnlyDictionary usedVersions, + IReadOnlyList discoveredWinmds, + DirectoryInfo nugetCacheDir, + string? yamlPackagesHash = null) + { + // NuGet cache layout is lowercase; bucket by lowercased id. Output + // entries keep usedVersions's original casing. + var winmdsByPackage = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var w in discoveredWinmds) + { + var pkgIdLc = JsBindingsPresets.ExtractPackageIdFromPath(w.FullName, nugetCacheDir.FullName); + if (pkgIdLc is null) + { + continue; + } + if (!winmdsByPackage.TryGetValue(pkgIdLc, out var list)) + { + list = new List(); + winmdsByPackage[pkgIdLc] = list; + } + list.Add(w.FullName); + } + + var packages = new List(usedVersions.Count); + foreach (var (name, version) in usedVersions) + { + var pkgIdLc = name.ToLowerInvariant(); + winmdsByPackage.TryGetValue(pkgIdLc, out var winmds); + var category = JsBindingsPresets.ClassifyPackage(name) switch + { + WinmdPackageCategory.Skip => "skip", + WinmdPackageCategory.RefOnly => "refOnly", + _ => "emit", + }; + packages.Add(new WinmdsLockfilePackage + { + Name = name, + Version = version, + Category = category, + Winmds = winmds is null + ? new List() + : winmds.OrderBy(p => p, StringComparer.OrdinalIgnoreCase).ToList(), + }); + } + + // Stable diff-friendly order: alpha by package name. + packages.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase)); + + return new WinmdsLockfile + { + Schema = WinmdsLockfile.CurrentSchema, + GeneratedAt = DateTimeOffset.UtcNow.ToString("O"), + NugetCacheDir = nugetCacheDir.FullName, + YamlPackagesHash = yamlPackagesHash, + Packages = packages, + }; + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.cs b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.cs index e3849b8c..1444615a 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.cs @@ -10,9 +10,7 @@ namespace WinApp.Cli.Services; -/// -/// Parameters for workspace setup operations -/// +// Parameters for workspace setup operations internal class WorkspaceSetupOptions { public required DirectoryInfo BaseDirectory { get; set; } @@ -24,18 +22,57 @@ internal class WorkspaceSetupOptions public bool RequireExistingConfig { get; set; } public bool ForceLatestBuildTools { get; set; } public bool ConfigOnly { get; set; } + + // Enable JS/TS bindings generation in Step 5.5 of setup. + public bool AddJsBindings { get; set; } + + // CLI override for jsBindings.output. + public string? JsBindingsOutputOverride { get; set; } + + // CLI override for jsBindings.lang. + public string? JsBindingsLangOverride { get; set; } + + // Preset names from JsBindingsPresets — unioned into jsBindings.packages. + public IReadOnlyList? JsBindingsPresets { get; set; } } -/// -/// Shared service for setting up winapp workspaces -/// +// Params for AddJsBindingsAsync. +internal class AddJsBindingsOptions +{ + public required DirectoryInfo BaseDirectory { get; set; } + public required DirectoryInfo ConfigDir { get; set; } + + // CLI override for jsBindings.output. + public string? Output { get; set; } + + // Preset names from JsBindingsPresets. + public IReadOnlyList? Presets { get; set; } + + // Patch an existing jsBindings: block without prompting. + public bool Force { get; set; } + + // Preserve an existing jsBindings: block and exit 0 without prompting. + // Mutually exclusive with Force. + public bool UseDefaults { get; set; } +} + +// Params for the read-only `node jsbindings generate` flow. +internal class GenerateJsBindingsOptions +{ + public required DirectoryInfo BaseDirectory { get; set; } + public required DirectoryInfo ConfigDir { get; set; } +} + +// Shared service for setting up winapp workspaces internal class WorkspaceSetupService( IConfigService configService, IWinappDirectoryService winappDirectoryService, IPackageInstallationService packageInstallationService, IBuildToolsService buildToolsService, ICppWinrtService cppWinrtService, + IJsBindingsWorkspaceService jsBindingsWorkspaceService, IPackageLayoutService packageLayoutService, + IWinmdsLockfileService winmdsLockfileService, IPackageRegistrationService packageRegistrationService, INugetService nugetService, IManifestService manifestService, @@ -52,6 +89,18 @@ public async Task SetupWorkspaceAsync(WorkspaceSetupOptions options, Cancel { configService.ConfigPath = new FileInfo(Path.Combine(options.ConfigDir.FullName, "winapp.yaml")); + // --js-bindings needs installed SDK packages; --setup-sdks none would + // produce a silent no-op. + if (options.AddJsBindings && options.SdkInstallMode == SdkInstallMode.None) + { + logger.LogError( + "{UISymbol} --js-bindings requires SDK packages to be installed (it walks the NuGet cache for .winmd files), but --setup-sdks none was specified. " + + "Either drop --js-bindings here and add it later via 'npx winapp node jsbindings add' after restoring SDKs, " + + "or change --setup-sdks to a value other than none (stable / preview / experimental).", + UiSymbols.Error); + return 1; + } + // Detect .NET project (.csproj) in the base directory FileInfo? csprojFile = null; bool isDotNetProject = false; @@ -74,6 +123,19 @@ public async Task SetupWorkspaceAsync(WorkspaceSetupOptions options, Cancel return 1; } + // --js-bindings is unsupported on .NET projects — the local .winapp/ + // workspace isn't initialized for them, so codegen would silently + // skip downstream. Reject up-front with an actionable error. + if (isDotNetProject && options.AddJsBindings) + { + logger.LogError( + "{UISymbol} --js-bindings is not supported for .NET (.csproj) projects yet. " + + "JS/TS bindings target native / Node-hosted apps. Run 'winapp init' (without --js-bindings) " + + "on the .NET project, and use 'winapp init --js-bindings' from your Node / Electron host instead.", + UiSymbols.Error); + return 1; + } + // Configuration / prompting phase bool hadExistingConfig; WinappConfig? config; @@ -104,6 +166,18 @@ public async Task SetupWorkspaceAsync(WorkspaceSetupOptions options, Cancel logger.LogInformation("{UISymbol} {PackageName} = {PackageVersion}", UiSymbols.Bullet, pkg.Name, pkg.Version); } } + + // Q3: when re-init adds a jsBindings block to an already-existing + // winapp.yaml (the v1.0 user case), --config-only would otherwise + // skip the save and the user would see no effect. Persist here + // so the freshly-injected jsBindings actually lands on disk. + if (options.AddJsBindings && config.JsBindings is not null) + { + // Splice-save: preserve any user-edited comments + unknown + // fields in the existing yaml. + configService.SaveJsBindingsOnly(config); + logger.LogDebug("{UISymbol} Persisted updated configuration with jsBindings → {ConfigPath}", UiSymbols.Save, configService.ConfigPath); + } } else if (options.SdkInstallMode != SdkInstallMode.None) { @@ -127,7 +201,12 @@ public async Task SetupWorkspaceAsync(WorkspaceSetupOptions options, Cancel } } - var finalConfig = new WinappConfig(); + var finalConfig = new WinappConfig + { + // Preserve any JsBindings block the user set (or that --js-bindings + // injected upstream) so re-running init doesn't strip it. + JsBindings = config?.JsBindings, + }; foreach (var kvp in defaultVersions) { finalConfig.SetVersion(kvp.Key, kvp.Value); @@ -560,6 +639,20 @@ await taskContext.AddSubTaskAsync("Configuring developer mode", async (taskConte return (2, "No .winmd files found for C++/WinRT projection."); } + // Persist the lockfile so subsequent `node jsbindings add` + // can skip re-globbing / re-fetching nuspecs. + // + // Hash source must match what eventually lands in + // winapp.yaml: restore uses config.Packages directly; + // fresh init filters usedVersions to SDK_PACKAGES first + // (same filter applied to yaml at ~line 929). + var yamlHash = (options.RequireExistingConfig && config?.Packages.Count > 0) + ? YamlPackagesHasher.Compute(config.Packages) + : YamlPackagesHasher.ComputeFromVersions(usedVersions + .Where(kvp => NugetService.SDK_PACKAGES.Contains(kvp.Key, StringComparer.OrdinalIgnoreCase))); + await winmdsLockfileService.WriteAsync( + localWinappDir, usedVersions, winmds, nugetCacheDir, yamlHash, cancellationToken); + // Run cppwinrt taskContext.UpdateSubStatus("Generating C++/WinRT projections"); await cppWinrtService.RunWithRspAsync(cppWinrtExe, winmds, includeOut, localWinappDir, taskContext, cancellationToken: cancellationToken); @@ -612,6 +705,15 @@ await taskContext.AddSubTaskAsync("Configuring developer mode", async (taskConte } } + // Step 5.5: Generate JS/TS bindings (opt-in via jsBindings: in winapp.yaml) + var jsBindingsStep = await MaybeRunJsBindingsStepAsync( + config, usedVersions, nugetCacheDir, localWinappDir, + options, taskContext, cancellationToken); + if (jsBindingsStep is { } failed) + { + return failed; + } + // Install Windows App SDK Runtime (shared: both .NET and native paths) if (options.SdkInstallMode != SdkInstallMode.None) { @@ -682,7 +784,12 @@ await taskContext.AddSubTaskAsync("Installing Windows App SDK Runtime", async (t await taskContext.AddSubTaskAsync("Saving configuration", (taskContext, cancellationToken) => { // Setup: Save winapp.yaml with used versions - var finalConfig = new WinappConfig(); + var finalConfig = new WinappConfig + { + // Preserve any JsBindings block the user set (or that --js-bindings + // injected upstream) so the persisted yaml round-trips correctly. + JsBindings = config?.JsBindings, + }; // only from SDK_PACKAGES var versionsToSave = usedVersions .Where(kvp => NugetService.SDK_PACKAGES.Contains(kvp.Key, StringComparer.OrdinalIgnoreCase)) @@ -774,9 +881,7 @@ await taskContext.AddSubTaskAsync("Updating Directory.Packages.props", (taskCont }, cancellationToken); } - /// - /// Selects the .csproj file to configure when multiple are found. - /// + // Selects the .csproj file to configure when multiple are found. private async Task SelectCsprojFileAsync(IReadOnlyList csprojFiles, CancellationToken cancellationToken) { if (csprojFiles.Count == 1) @@ -794,6 +899,56 @@ private async Task SelectCsprojFileAsync(IReadOnlyList cspro return csprojFiles.First(f => f.Name == selected); } + // Runs the JS-bindings step when its prerequisites (config, restore + // outputs, workspace dir) are all present. Returns null when skipped + // or when the step succeeded; returns a non-zero (exitCode, message) + // tuple when the step ran and failed, which the caller forwards as + // the overall init/restore result. Internal so unit tests can exercise + // it directly with a fake IJsBindingsWorkspaceService. + internal async Task<(int, string)?> MaybeRunJsBindingsStepAsync( + WinappConfig? config, + Dictionary? usedVersions, + DirectoryInfo? nugetCacheDir, + DirectoryInfo? localWinappDir, + WorkspaceSetupOptions options, + TaskContext taskContext, + CancellationToken cancellationToken) + { + if (config?.JsBindings is null + || usedVersions is null + || nugetCacheDir is null + || localWinappDir is null) + { + return null; + } + + var jsBindingsResult = await taskContext.AddSubTaskAsync("Generating JS bindings", async (taskContext, cancellationToken) => + { + var orchResult = await jsBindingsWorkspaceService.RunAsync( + new JsBindingsOrchestrationContext + { + JsBindingsConfig = config.JsBindings, + WinappConfig = config, + WorkspaceDir = options.BaseDirectory, + LocalWinappDir = localWinappDir, + NugetCacheDir = nugetCacheDir, + UsedVersions = usedVersions, + }, + taskContext, + cancellationToken); + return (orchResult.ExitCode, orchResult.Message); + }, cancellationToken); + + // Propagate sub-task failure to the parent init/restore flow. + // Otherwise init reports overall success even when bindings + // didn't generate — silently shipping a broken workspace. + if (jsBindingsResult.Item1 != 0) + { + return jsBindingsResult; + } + return null; + } + private async Task SetupManifestSubTaskAsync(WorkspaceSetupOptions options, bool shouldGenerateManifest, ManifestGenerationInfo? manifestGenerationInfo, TaskContext taskContext, CancellationToken cancellationToken) { await taskContext.AddSubTaskAsync("Generating Manifest and Assets", async (taskContext, cancellationToken) => @@ -867,6 +1022,23 @@ await manifestService.GenerateManifestAsync( var operation = options.RequireExistingConfig ? "Found" : "Found existing"; logger.LogDebug("{UISymbol} {Operation} winapp.yaml with {PackageCount} packages", UiSymbols.Package, operation, config.Packages.Count); + // Re-init UX: surface the JS bindings capability when the user + // hasn't opted in yet. npm-shim only — winget users can't use + // --js-bindings. + if (!options.RequireExistingConfig + && !options.AddJsBindings + && config.JsBindings is null + && string.Equals( + Environment.GetEnvironmentVariable("WINAPP_CLI_CALLER"), + "nodejs-package", + StringComparison.Ordinal)) + { + // Informational hint — must respect --quiet. + logger.LogInformation( + "{UISymbol} To add JS/TS bindings to this project, re-run: npx winapp init --js-bindings", + UiSymbols.Info); + } + if (!options.RequireExistingConfig && config.Packages.Count > 0) { logger.LogDebug("{UISymbol} Using pinned package versions from winapp.yaml unless overridden.", UiSymbols.Note); @@ -914,6 +1086,98 @@ await manifestService.GenerateManifestAsync( } } + // Re-check after AskSdkInstallModeAsync: the interactive prompt can + // land on SdkInstallMode=None, which silently breaks --js-bindings + // (codegen has no packages to walk). + if (options.AddJsBindings && options.SdkInstallMode == SdkInstallMode.None) + { + logger.LogError( + "{UISymbol} --js-bindings requires SDK packages but the SDK install mode was set to 'none'. " + + "Re-run without --js-bindings, or pick a non-'none' SDK mode (stable / preview / experimental).", + UiSymbols.Error); + return (1, config, hadExistingConfig, shouldGenerateManifest, manifestGenerationInfo, shouldEnableDeveloperMode, recommendedTfm); + } + + // --js-bindings: fill in a default block when none exists. Never + // overwrites a user-defined block. + if (options.AddJsBindings && config != null && config.JsBindings is not null) + { + // Warn when override flags were passed but the yaml already had + // a jsBindings block — we won't apply them silently. + var hasOverrides = !string.IsNullOrWhiteSpace(options.JsBindingsOutputOverride) + || !string.IsNullOrWhiteSpace(options.JsBindingsLangOverride) + || (options.JsBindingsPresets is { Count: > 0 }); + if (hasOverrides) + { + logger.LogWarning( + "{UISymbol} --js-bindings-output / --js-bindings-lang / --js-bindings-{{preset}} are " + + "ignored because winapp.yaml already declares a jsBindings block. " + + "Use 'npx winapp node jsbindings add --force' to overwrite specific fields.", + UiSymbols.Warning); + } + } + if (options.AddJsBindings && config != null && config.JsBindings is null) + { + var jsCfg = new JsBindingsConfig(); + if (!string.IsNullOrWhiteSpace(options.JsBindingsOutputOverride)) + { + jsCfg.Output = options.JsBindingsOutputOverride!.Trim(); + } + if (!string.IsNullOrWhiteSpace(options.JsBindingsLangOverride)) + { + jsCfg.Lang = options.JsBindingsLangOverride!.Trim(); + } + + // Validate the resolved output path before persisting anything — + // mirrors the add-jsbindings path and prevents an invalid + // --js-bindings-output value from corrupting winapp.yaml. + try + { + DynWinrtCodegenService.ResolveOutputDir(options.BaseDirectory, jsCfg.Output); + } + catch (InvalidOperationException ex) + { + logger.LogError( + "{UISymbol} Invalid --js-bindings-output: {Reason}", + UiSymbols.Error, ex.Message); + return (1, config, hadExistingConfig, shouldGenerateManifest, manifestGenerationInfo, shouldEnableDeveloperMode, recommendedTfm); + } + if (options.JsBindingsPresets is { Count: > 0 } presetNames) + { + var packageIds = JsBindingsPresets.ResolveAndUnion(presetNames); + if (packageIds.Count > 0) + { + jsCfg.Packages = new List(packageIds); + logger.LogDebug( + "{UISymbol} jsBindings presets [{Presets}] → packages=[{Packages}]", + UiSymbols.New, + string.Join(", ", presetNames), + string.Join(", ", packageIds)); + } + else + { + // Defensive: InitCommand validates preset names before + // this is reached. + logger.LogWarning( + "{UISymbol} jsBindings presets [{Presets}] resolved to no prefixes; ignoring (known: {Known}).", + UiSymbols.Warning, + string.Join(", ", presetNames), + JsBindingsPresets.KnownPresetsDisplay()); + } + } + config.JsBindings = jsCfg; + logger.LogDebug( + "{UISymbol} --js-bindings: added default jsBindings block (lang={Lang}, output={Output})", + UiSymbols.New, + config.JsBindings.Lang, + config.JsBindings.Output); + + // Generated bindings import @microsoft/dynwinrt at runtime — must + // be a production dep (not just a transitive of the devDep + // wrapper). Print a PM-aware install hint. + jsBindingsWorkspaceService.EnsureRuntimeDependencyAndPrintHint(options.BaseDirectory); + } + // .NET: Validate TargetFramework (interactive) if (isDotNetProject && csprojFile != null) { @@ -1153,21 +1417,14 @@ string FormatChoice(string modeLabel, SdkInstallMode mode) } } - /// - /// Package entry information from MSIX inventory - /// + // Package entry information from MSIX inventory public class MsixPackageEntry { public required string FileName { get; set; } public required string PackageIdentity { get; set; } } - /// - /// Parses the MSIX inventory file and returns package entries (shared implementation) - /// - /// Directory containing the MSIX packages - /// Cancellation token - /// List of package entries, or null if not found + // Parses the MSIX inventory file and returns package entries (shared implementation) public static async Task?> ParseMsixInventoryAsync(TaskContext taskContext, DirectoryInfo msixDir, CancellationToken cancellationToken) { var architecture = GetSystemArchitecture(); @@ -1210,11 +1467,9 @@ public class MsixPackageEntry return packageEntries; } - /// - /// Reads the actual package Name and Version from the AppxManifest.xml inside an MSIX file. - /// The MSIX inventory file can have incorrect package names (e.g., the DDLM), so we read - /// the real identity directly from the package to ensure correct installation checks. - /// + // Reads the actual package Name and Version from the AppxManifest.xml inside an MSIX file. + // The MSIX inventory file can have incorrect package names (e.g., the DDLM), so we read + // the real identity directly from the package to ensure correct installation checks. private static (string? Name, string? Version) ReadMsixIdentity(string msixFilePath, TaskContext taskContext) { try @@ -1242,11 +1497,7 @@ private static (string? Name, string? Version) ReadMsixIdentity(string msixFileP } } - /// - /// Installs Windows App SDK runtime MSIX packages for the current system architecture - /// - /// Directory containing the MSIX packages - /// Cancellation token + // Installs Windows App SDK runtime MSIX packages for the current system architecture public async Task<(int InstalledCount, int ErrorCount)> InstallWindowsAppRuntimeAsync(DirectoryInfo msixDir, TaskContext taskContext, CancellationToken cancellationToken) { var architecture = GetSystemArchitecture(); @@ -1338,10 +1589,7 @@ private static (string? Name, string? Version) ReadMsixIdentity(string msixFileP return (installedCount, errorCount); } - /// - /// Gets the current system architecture string for package selection - /// - /// Architecture string (x64, arm64, x86) + // Gets the current system architecture string for package selection public static string GetSystemArchitecture() { var arch = RuntimeInformation.ProcessArchitecture; @@ -1354,20 +1602,14 @@ public static string GetSystemArchitecture() }; } - /// - /// Finds the MSIX directory for Windows App SDK runtime packages - /// - /// Optional dictionary of package versions to look for specific installed packages - /// The path to the MSIX directory, or null if not found + // Finds the MSIX directory for Windows App SDK runtime packages public DirectoryInfo? FindWindowsAppSdkMsixDirectory(Dictionary? usedVersions = null) { var nugetCacheDir = nugetService.GetNuGetGlobalPackagesDir(); return FindMsixDirectoryInNuGetCache(nugetCacheDir, usedVersions); } - /// - /// Searches the NuGet global packages cache (lowercase id/version folder convention). - /// + // Searches the NuGet global packages cache (lowercase id/version folder convention). private static DirectoryInfo? FindMsixDirectoryInNuGetCache(DirectoryInfo nugetCacheDir, Dictionary? usedVersions) { if (usedVersions != null) @@ -1424,9 +1666,7 @@ public static string GetSystemArchitecture() return null; } - /// - /// Checks the NuGet cache for a specific package/version (lowercase ID/version layout). - /// + // Checks the NuGet cache for a specific package/version (lowercase ID/version layout). private static DirectoryInfo? TryGetMsixDirectoryFromNuGetCache(DirectoryInfo nugetCacheDir, string packageId, string version) { // NuGet global cache uses lowercase package IDs @@ -1434,22 +1674,16 @@ public static string GetSystemArchitecture() return TryGetMsixDirectoryFromPath(pkgVersionDir); } - /// - /// Helper method to check if an MSIX directory exists for a given package path - /// - /// The full path to the package directory - /// The MSIX directory path if it exists, null otherwise + // Helper method to check if an MSIX directory exists for a given package path private static DirectoryInfo? TryGetMsixDirectoryFromPath(DirectoryInfo packagePath) { var msixDir = new DirectoryInfo(Path.Combine(packagePath.FullName, "tools", "MSIX")); return msixDir.Exists ? msixDir : null; } - /// - /// Runs while showing a Spectre.Console spinner with . - /// In non-interactive contexts (redirected output, no Information logging), falls back to a single - /// log line so the user still sees what's happening (#463). - /// + // Runs work while showing a Spectre.Console spinner with message. + // In non-interactive contexts (redirected output, no Information logging), falls back to a single + // log line so the user still sees what's happening (#463). private async Task RunWithStatusAsync(string message, Func> work, CancellationToken cancellationToken) { if (Environment.UserInteractive @@ -1471,9 +1705,7 @@ await ansiConsole.Status() return await work(cancellationToken); } - /// - /// Comparer for sorting version strings, including prerelease support - /// + // Comparer for sorting version strings, including prerelease support private class VersionStringComparer : IComparer { public int Compare(string? x, string? y) diff --git a/src/winapp-CLI/WinApp.Cli/Services/YamlPackagesHasher.cs b/src/winapp-CLI/WinApp.Cli/Services/YamlPackagesHasher.cs new file mode 100644 index 00000000..b0783063 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/YamlPackagesHasher.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Security.Cryptography; +using System.Text; +using WinApp.Cli.Models; + +namespace WinApp.Cli.Services; + +// SHA-256 hex over sorted `lower(name)|version` lines from the yaml +// `packages:` block. Used as a staleness check for the winmds lockfile. +internal static class YamlPackagesHasher +{ + public static string Compute(IEnumerable packages) + { + var pairs = packages + .Where(p => !string.IsNullOrWhiteSpace(p.Name)) + .Select(p => KeyValuePair.Create(p.Name, p.Version ?? string.Empty)); + return ComputeFromVersions(pairs); + } + + // Accepts raw (name, version) pairs — used during fresh init. + public static string ComputeFromVersions(IEnumerable> versions) + { + var lines = versions + .Where(kvp => !string.IsNullOrWhiteSpace(kvp.Key)) + .Select(kvp => $"{kvp.Key.ToLowerInvariant()}|{kvp.Value}") + .Distinct(StringComparer.Ordinal) + .OrderBy(s => s, StringComparer.Ordinal) + .ToArray(); + var joined = string.Join("\n", lines); + var bytes = Encoding.UTF8.GetBytes(joined); + var hash = SHA256.HashData(bytes); + return Convert.ToHexString(hash).ToLowerInvariant(); + } +} diff --git a/src/winapp-npm/README.md b/src/winapp-npm/README.md index 40394ef5..c91ba329 100644 --- a/src/winapp-npm/README.md +++ b/src/winapp-npm/README.md @@ -42,6 +42,7 @@ npx winapp --help - [`init`](https://github.com/microsoft/WinAppCli/blob/main/docs/usage.md#init) - Initialize project with Windows SDK and App SDK - [`restore`](https://github.com/microsoft/WinAppCli/blob/main/docs/usage.md#restore) - Restore packages and dependencies - [`update`](https://github.com/microsoft/WinAppCli/blob/main/docs/usage.md#update) - Update packages and dependencies to latest versions +- [`node jsbindings add`](https://github.com/microsoft/WinAppCli/blob/main/docs/usage.md#node-add-jsbindings) - Add typed JS/TypeScript WinRT bindings to an existing workspace **App Identity & Debugging:** diff --git a/src/winapp-npm/package-lock.json b/src/winapp-npm/package-lock.json index e23ab727..05df349c 100644 --- a/src/winapp-npm/package-lock.json +++ b/src/winapp-npm/package-lock.json @@ -12,6 +12,9 @@ "os": [ "win32" ], + "dependencies": { + "@microsoft/dynwinrt-codegen": "0.1.0-preview.1" + }, "bin": { "winapp": "dist/cli.js" }, @@ -196,6 +199,18 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@microsoft/dynwinrt-codegen": { + "version": "0.1.0-preview.1", + "resolved": "https://registry.npmjs.org/@microsoft/dynwinrt-codegen/-/dynwinrt-codegen-0.1.0-preview.1.tgz", + "integrity": "sha512-pyVb/xAXRwTf8WNkIqccm/CriHtAio09vZYv3kg/5pjgNVh0WkNogvd2Sup1rloyi5pobgAbd85DhedGa+/8bQ==", + "license": "MIT", + "os": [ + "win32" + ], + "bin": { + "dynwinrt-codegen": "cli.js" + } + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", diff --git a/src/winapp-npm/package.json b/src/winapp-npm/package.json index 7292e26b..a32b5cf7 100644 --- a/src/winapp-npm/package.json +++ b/src/winapp-npm/package.json @@ -51,6 +51,9 @@ "os": [ "win32" ], + "dependencies": { + "@microsoft/dynwinrt-codegen": "0.1.0-preview.1" + }, "devDependencies": { "@eslint/js": "^10.0.1", "@types/node": "^25.5.0", diff --git a/src/winapp-npm/scripts/generate-commands.mjs b/src/winapp-npm/scripts/generate-commands.mjs index e4bc8ac2..ab2dd7fe 100644 --- a/src/winapp-npm/scripts/generate-commands.mjs +++ b/src/winapp-npm/scripts/generate-commands.mjs @@ -73,10 +73,16 @@ function kebabToPascal(s) { return cc.charAt(0).toUpperCase() + cc.slice(1); } -/** Clean up CLI description for JSDoc (single line, no trailing period). */ +// Clean up CLI description for JSDoc: single line, escape `*/` (closes the +// JSDoc) and `@` (truncates description in TS doc extractors). function cleanDesc(desc) { if (!desc) return ''; - return desc.replace(/\r?\n/g, ' ').replace(/\s+/g, ' ').trim(); + return desc + .replace(/\r?\n/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .replace(/\*\//g, '*\\/') + .replace(/@/g, '\\@'); } const COMMON_OPTIONS = new Set(['--quiet', '--verbose', '--help']); @@ -248,7 +254,7 @@ function generate(schema) { for (const { path: cmdPath, cmd } of commands) { const cmdPathStr = cmdPath.join(' '); const fnName = getFunctionName(cmdPath); - const ifaceName = kebabToPascal(cmdPath.join('-')) + 'Options'; + const ifaceName = getInterfaceName(cmdPath); // Check for passthrough command const passthrough = PASSTHROUGH_COMMANDS[cmdPath.join(' ')] || null; @@ -355,6 +361,11 @@ function generate(schema) { // --------------------------------------------------------------------------- const FN_NAME_OVERRIDES = { 'package': 'packageApp', // `package` is a TS reserved-ish word + 'add jsbindings': 'addJsBindings', // canonical camelCase for the compound name +}; + +const IFACE_NAME_OVERRIDES = { + 'add jsbindings': 'AddJsBindingsOptions', }; function getFunctionName(cmdPath) { @@ -366,6 +377,12 @@ function getFunctionName(cmdPath) { return TS_RESERVED.has(name) ? name + 'Command' : name; } +function getInterfaceName(cmdPath) { + const key = cmdPath.join(' '); + if (IFACE_NAME_OVERRIDES[key]) return IFACE_NAME_OVERRIDES[key]; + return kebabToPascal(cmdPath.join('-')) + 'Options'; +} + // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- diff --git a/src/winapp-npm/scripts/generate-docs.mjs b/src/winapp-npm/scripts/generate-docs.mjs index 8632e60a..52a99ea8 100644 --- a/src/winapp-npm/scripts/generate-docs.mjs +++ b/src/winapp-npm/scripts/generate-docs.mjs @@ -92,7 +92,53 @@ for (const sym of allExports) { // --------------------------------------------------------------------------- // Extraction helpers // --------------------------------------------------------------------------- + +// Strip JSDoc markers and return description text, stopping at the first +// `@tag` line. Mid-prose `@microsoft/...` references are preserved (TS's +// own `displayPartsToString` truncates at any `@`). +function rawDescFromJsDocText(rawCommentText) { + if (!rawCommentText) return ''; + let text = rawCommentText.trim(); + if (text.startsWith('/**')) text = text.slice(3); + if (text.endsWith('*/')) text = text.slice(0, -2); + + const lines = text.split(/\r?\n/); + const out = []; + for (const rawLine of lines) { + const line = rawLine.replace(/^\s*\*\s?/, ''); + // A JSDoc tag at start-of-line ends the description. + if (/^@\w/.test(line.trimStart()) && line.trimStart().startsWith('@')) { + break; + } + out.push(line); + } + return out.join(' ').replace(/\s+/g, ' ').trim(); +} + +// Read the leading /** */ block for a declaration directly from source, +// preserving `@microsoft/...` references that TS's doc API would truncate. +function getRawJsDoc(decl) { + if (!decl) return null; + const sourceFile = decl.getSourceFile(); + const sourceText = sourceFile.getFullText(); + const ranges = ts.getLeadingCommentRanges(sourceText, decl.getFullStart()); + if (!ranges || ranges.length === 0) return null; + for (let i = ranges.length - 1; i >= 0; i--) { + const r = ranges[i]; + const slice = sourceText.slice(r.pos, r.end); + if (slice.startsWith('/**')) return slice; + } + return null; +} + function getDoc(sym) { + // Prefer raw source extraction to keep mid-prose `@` sequences intact. + const decl = sym.valueDeclaration ?? sym.declarations?.[0]; + const raw = getRawJsDoc(decl); + if (raw) { + const txt = rawDescFromJsDocText(raw); + if (txt) return txt; + } return ts.displayPartsToString(sym.getDocumentationComment(checker)).trim(); } @@ -325,6 +371,7 @@ function generate() { const lines = []; const L = (s = '') => lines.push(s); + L(''); L(''); L(''); L(); diff --git a/src/winapp-npm/src/cli.ts b/src/winapp-npm/src/cli.ts index b87e9851..4fb268ad 100644 --- a/src/winapp-npm/src/cli.ts +++ b/src/winapp-npm/src/cli.ts @@ -86,7 +86,7 @@ async function handleNodeCommand(command: string, args: string[]): Promise // Node.js wrapper-only commands that should appear in completions const NODE_WRAPPER_COMMANDS = ['node']; -const NODE_SUBCOMMANDS = ['create-addon', 'add-electron-debug-identity', 'clear-electron-debug-identity']; +const NODE_SUBCOMMANDS = ['jsbindings', 'create-addon', 'add-electron-debug-identity', 'clear-electron-debug-identity']; /** * Handle completion requests by forwarding to the native CLI and augmenting @@ -267,11 +267,15 @@ async function handleNode(args: string[]): Promise { console.log('Node.js-specific commands'); console.log(''); console.log('Subcommands:'); - console.log(' create-addon Generate native addon files for Electron'); - console.log(' add-electron-debug-identity Add package identity to Electron debug process'); - console.log(' clear-electron-debug-identity Remove package identity from Electron debug process'); + console.log(' jsbindings add Add a jsBindings: block to winapp.yaml + run codegen'); + console.log(' jsbindings generate Re-run codegen against the existing jsBindings: block'); + console.log(' create-addon Generate native addon files for Electron'); + console.log(' add-electron-debug-identity Add package identity to Electron debug process'); + console.log(' clear-electron-debug-identity Remove package identity from Electron debug process'); console.log(''); console.log('Examples:'); + console.log(` ${CLI_NAME} node jsbindings add --ai`); + console.log(` ${CLI_NAME} node jsbindings generate`); console.log(` ${CLI_NAME} node create-addon --help`); console.log(` ${CLI_NAME} node create-addon --name myAddon`); console.log(` ${CLI_NAME} node create-addon --name myCsAddon --template cs`); @@ -298,6 +302,12 @@ async function handleNode(args: string[]): Promise { await handleClearElectronDebugIdentity(subcommandArgs); break; + case 'jsbindings': + // Native-CLI sub-command tree (`node jsbindings add` / `... generate`). + // Forward the full argv (including the leading `node`) to the .NET CLI. + await callWinappCli(['node', ...args], { exitOnError: true }); + break; + default: console.error(`❌ Unknown node subcommand: ${subcommand}`); console.error(`Run "${CLI_NAME} node" for available subcommands.`); diff --git a/src/winapp-npm/src/winapp-commands.ts b/src/winapp-npm/src/winapp-commands.ts index 71acf95c..7dd613f7 100644 --- a/src/winapp-npm/src/winapp-commands.ts +++ b/src/winapp-npm/src/winapp-commands.ts @@ -247,6 +247,14 @@ export interface InitOptions extends CommonOptions { configOnly?: boolean; /** Don't use configuration file for version management */ ignoreConfig?: boolean; + /** Generate JS/TS bindings via dynwinrt-codegen on top of the standard init flow. Adds a 'jsBindings:' block to winapp.yaml so the binding generator runs as part of init/restore. Only available when invoked via the \@microsoft/winappcli npm package (npx winapp init --js-bindings). */ + jsBindings?: boolean; + /** Generate bindings for the 'ai' slice of the SDK. Implies --js-bindings (no need to pass it separately). For a custom slice that no preset covers, edit winapp.yaml and write your own packages: list under jsBindings. Known presets: ai. */ + jsBindingsAi?: boolean; + /** Override the JS bindings language. Currently only 'js' is supported (which emits both .js and .d.ts). Reserved for forward-compat; see --js-bindings-output for activation rules. */ + jsBindingsLang?: string; + /** Override the output directory for generated JS/TS bindings (relative to workspace, default 'bindings/winrt'). Only takes effect together with --js-bindings on a fresh init; ignored on re-init when winapp.yaml already declares jsBindings:. */ + jsBindingsOutput?: string; /** Don't update .gitignore file */ noGitignore?: boolean; /** SDK installation mode: 'stable' (default), 'preview', 'experimental', or 'none' (skip SDK installation) */ @@ -264,6 +272,10 @@ export async function init(options: InitOptions = {}): Promise { if (options.configDir) args.push('--config-dir', options.configDir); if (options.configOnly) args.push('--config-only'); if (options.ignoreConfig) args.push('--ignore-config'); + if (options.jsBindings) args.push('--js-bindings'); + if (options.jsBindingsAi) args.push('--js-bindings-ai'); + if (options.jsBindingsLang) args.push('--js-bindings-lang', options.jsBindingsLang); + if (options.jsBindingsOutput) args.push('--js-bindings-output', options.jsBindingsOutput); if (options.noGitignore) args.push('--no-gitignore'); if (options.setupSdks) args.push('--setup-sdks', options.setupSdks); if (options.useDefaults) args.push('--use-defaults'); @@ -360,6 +372,60 @@ export async function manifestUpdateAssets(options: ManifestUpdateAssetsOptions) return execCommand(args, options); } +// --------------------------------------------------------------------------- +// node jsbindings add +// --------------------------------------------------------------------------- + +export interface NodeJsbindingsAddOptions extends CommonOptions { + /** Base/root directory for the winapp workspace (default: current directory) */ + baseDirectory?: string; + /** Generate bindings for the 'ai' slice of the SDK only. For a custom slice that no preset covers, edit winapp.yaml's jsBindings.packages after adding. Known presets: ai. */ + ai?: boolean; + /** Directory containing winapp.yaml (default: base-directory) */ + configDir?: string; + /** Patch an existing jsBindings: block without prompting. Overwrites only output and (when a preset like --ai is supplied) the packages list; all other fields are preserved. Without --force the command refuses to clobber a pre-existing block (interactive: prompts; non-interactive: errors). */ + force?: boolean; + /** Output directory for generated JS/TS bindings (relative to workspace, default 'bindings/winrt'). Persisted to winapp.yaml's jsBindings.output field. */ + output?: string; + /** Do not prompt. When jsBindings: already exists in winapp.yaml, preserve it and exit 0 (idempotent). Use --force instead if you want the existing block patched non-interactively. */ + useDefaults?: boolean; +} + +/** + * Add a jsBindings: block to winapp.yaml and run codegen. Requires winapp.yaml (run 'winapp init' first). Never modifies the packages: section or installs SDK packages — codegen runs against the workspace's already-restored packages. Refuses to clobber an existing jsBindings: block unless --force is passed. Only available when invoked via the \@microsoft/winappcli npm package (npx winapp node jsbindings add). + */ +export async function nodeJsbindingsAdd(options: NodeJsbindingsAddOptions = {}): Promise { + const args: string[] = ['node', 'jsbindings', 'add']; + if (options.baseDirectory) args.push(options.baseDirectory); + if (options.ai) args.push('--ai'); + if (options.configDir) args.push('--config-dir', options.configDir); + if (options.force) args.push('--force'); + if (options.output) args.push('--output', options.output); + if (options.useDefaults) args.push('--use-defaults'); + return execCommand(args, options); +} + +// --------------------------------------------------------------------------- +// node jsbindings generate +// --------------------------------------------------------------------------- + +export interface NodeJsbindingsGenerateOptions extends CommonOptions { + /** Base/root directory for the winapp workspace (default: current directory) */ + baseDirectory?: string; + /** Directory containing winapp.yaml (default: base-directory) */ + configDir?: string; +} + +/** + * Re-run dynwinrt-codegen against the existing jsBindings: block in winapp.yaml. Does NOT modify the yaml — for that, use 'node jsbindings add'. Errors if no jsBindings: block is declared. Only available when invoked via the \@microsoft/winappcli npm package (npx winapp node jsbindings generate). + */ +export async function nodeJsbindingsGenerate(options: NodeJsbindingsGenerateOptions = {}): Promise { + const args: string[] = ['node', 'jsbindings', 'generate']; + if (options.baseDirectory) args.push(options.baseDirectory); + if (options.configDir) args.push('--config-dir', options.configDir); + return execCommand(args, options); +} + // --------------------------------------------------------------------------- // package // --------------------------------------------------------------------------- From be1ca74d121d4f25882122cf631fa470f6b7adaf Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Mon, 18 May 2026 15:21:01 +0800 Subject: [PATCH 02/27] fix testing code style --- .../AddJsBindingsOrchestrationTests.cs | 9 ++- .../ConfigServiceJsBindingsTests.cs | 57 +++++++++++-------- .../WinApp.Cli.Tests/InitCommandTests.cs | 5 +- .../JsBindingsPresetsTests.cs | 29 ++++++---- .../WinmdsLockfileServiceTests.cs | 4 +- 5 files changed, 66 insertions(+), 38 deletions(-) diff --git a/src/winapp-CLI/WinApp.Cli.Tests/AddJsBindingsOrchestrationTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/AddJsBindingsOrchestrationTests.cs index f6fc2cd1..5aa44ab1 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/AddJsBindingsOrchestrationTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/AddJsBindingsOrchestrationTests.cs @@ -18,6 +18,8 @@ namespace WinApp.Cli.Tests; [DoNotParallelize] public class AddJsBindingsOrchestrationTests : BaseCommandTests { + private static readonly string[] _arr00 = ["Lens", "Sensor"]; + private FakeDynWinrtCodegenService _fakeCodegen = null!; protected override IServiceCollection ConfigureServices(IServiceCollection services) @@ -87,7 +89,10 @@ private DirectoryInfo SetUpWorkspaceWithLockfile( foreach (var path in pkg.winmdPaths) { Directory.CreateDirectory(Path.GetDirectoryName(path)!); - if (!File.Exists(path)) File.WriteAllText(path, "stub winmd"); + if (!File.Exists(path)) + { + File.WriteAllText(path, "stub winmd"); + } } } @@ -703,7 +708,7 @@ public async Task AddJsBindings_ExtraTypesOnlyWithAdditionalRefs_Succeeds() Assert.AreEqual(1, call.Config.ExtraTypes.Count, "extraTypes must be passed through."); Assert.AreEqual("Vendor.SDK.Camera", call.Config.ExtraTypes[0].Namespace); CollectionAssert.AreEquivalent( - new[] { "Lens", "Sensor" }, + _arr00, call.Config.ExtraTypes[0].Classes.ToList()); } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/ConfigServiceJsBindingsTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/ConfigServiceJsBindingsTests.cs index 1647ff5f..b601e7ff 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/ConfigServiceJsBindingsTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/ConfigServiceJsBindingsTests.cs @@ -9,6 +9,22 @@ namespace WinApp.Cli.Tests; [TestClass] public class ConfigServiceJsBindingsTests : BaseCommandTests { + private static readonly string[] _arr00 = ["Microsoft.WindowsAppSDK.AI"]; + private static readonly string[] _arr01 = ["Microsoft.WindowsAppSDK.AI", "Microsoft.WindowsAppSDK"]; + private static readonly string[] _arr02 = ["vendor/BigVendor.winmd", @"C:\abs\OtherSdk.winmd"]; + private static readonly string[] _arr03 = ["vendor/Foo.winmd"]; + private static readonly string[] _arr04 = ["Lens", "Sensor"]; + private static readonly string[] _arr05 = ["vendor/BigVendor.winmd", @"C:\shared\OtherCompany.SDK.winmd"]; + private static readonly string[] _arr06 = ["vendor/Foo.winmd", @"C:\abs\Bar.winmd"]; + private static readonly string[] _arr07 = ["vendor/MyCompany.Foo.winmd", @"C:\absolute\path\Other.winmd", "sibling.winmd"]; + private static readonly string[] _arr08 = ["Uri"]; + private static readonly string[] _arr09 = ["StorageFile"]; + private static readonly string[] _arr10 = ["BitmapDecoder"]; + private static readonly string[] _arr11 = ["StorageFile", "StorageFolder"]; + private static readonly string[] _arr12 = ["LimitedAccessFeatures"]; + private static readonly string[] _arr13 = ["Calendar"]; + private static readonly string[] _arr14 = ["Uri", "PropertyValue"]; + [TestMethod] public void Load_NoJsBindings_ReturnsNull() { @@ -77,16 +93,16 @@ public void Load_FullJsBindings_ParsesAllFields() Assert.IsNotNull(cfg.JsBindings); Assert.AreEqual("src/generated", cfg.JsBindings.Output); CollectionAssert.AreEqual( - new[] { "Microsoft.WindowsAppSDK.AI" }, + _arr00, cfg.JsBindings.Packages); Assert.AreEqual(2, cfg.JsBindings.ExtraTypes.Count); Assert.AreEqual("Windows.Foundation", cfg.JsBindings.ExtraTypes[0].Namespace); - CollectionAssert.AreEqual(new[] { "Uri", "PropertyValue" }, cfg.JsBindings.ExtraTypes[0].Classes); + CollectionAssert.AreEqual(_arr14, cfg.JsBindings.ExtraTypes[0].Classes); Assert.AreEqual("Windows.Globalization", cfg.JsBindings.ExtraTypes[1].Namespace); - CollectionAssert.AreEqual(new[] { "Calendar" }, cfg.JsBindings.ExtraTypes[1].Classes); + CollectionAssert.AreEqual(_arr13, cfg.JsBindings.ExtraTypes[1].Classes); } [TestMethod] @@ -109,9 +125,9 @@ public void Load_ExtraTypes_InlineFlowList_Parses() Assert.IsNotNull(cfg.JsBindings); Assert.AreEqual(3, cfg.JsBindings.ExtraTypes.Count); - CollectionAssert.AreEqual(new[] { "LimitedAccessFeatures" }, cfg.JsBindings.ExtraTypes[0].Classes); - CollectionAssert.AreEqual(new[] { "StorageFile", "StorageFolder" }, cfg.JsBindings.ExtraTypes[1].Classes); - CollectionAssert.AreEqual(new[] { "BitmapDecoder" }, cfg.JsBindings.ExtraTypes[2].Classes); + CollectionAssert.AreEqual(_arr12, cfg.JsBindings.ExtraTypes[0].Classes); + CollectionAssert.AreEqual(_arr11, cfg.JsBindings.ExtraTypes[1].Classes); + CollectionAssert.AreEqual(_arr10, cfg.JsBindings.ExtraTypes[2].Classes); } [TestMethod] @@ -131,7 +147,7 @@ public void Load_ExtraTypes_ScalarSingleClass_Parses() Assert.IsNotNull(cfg.JsBindings); Assert.AreEqual(1, cfg.JsBindings.ExtraTypes.Count); - CollectionAssert.AreEqual(new[] { "StorageFile" }, cfg.JsBindings.ExtraTypes[0].Classes); + CollectionAssert.AreEqual(_arr09, cfg.JsBindings.ExtraTypes[0].Classes); } [TestMethod] @@ -161,12 +177,12 @@ public void SaveAndLoad_RoundTripsJsBindings() Assert.AreEqual("js", roundTrip.JsBindings.Lang); Assert.AreEqual("bindings/winrt", roundTrip.JsBindings.Output); CollectionAssert.AreEqual( - new[] { "Microsoft.WindowsAppSDK.AI" }, + _arr00, roundTrip.JsBindings.Packages, "Round-trip must preserve the packages slice exactly."); Assert.AreEqual(1, roundTrip.JsBindings.ExtraTypes.Count); Assert.AreEqual("Windows.Foundation", roundTrip.JsBindings.ExtraTypes[0].Namespace); - CollectionAssert.AreEqual(new[] { "Uri" }, roundTrip.JsBindings.ExtraTypes[0].Classes); + CollectionAssert.AreEqual(_arr08, roundTrip.JsBindings.ExtraTypes[0].Classes); } [TestMethod] @@ -208,12 +224,7 @@ public void Load_AdditionalWinmds_ParsesRelativeAndAbsolutePaths() Assert.IsNotNull(cfg.JsBindings); CollectionAssert.AreEqual( - new[] - { - "vendor/MyCompany.Foo.winmd", - @"C:\absolute\path\Other.winmd", - "sibling.winmd", - }, + _arr07, cfg.JsBindings.AdditionalWinmds, "AdditionalWinmds entries must round-trip in declaration order, accepting both relative and absolute paths"); } @@ -259,7 +270,7 @@ public void SaveAndLoad_AdditionalWinmds_RoundTrips() Assert.IsNotNull(roundTrip.JsBindings); CollectionAssert.AreEqual( - new[] { "vendor/Foo.winmd", @"C:\abs\Bar.winmd" }, + _arr06, roundTrip.JsBindings.AdditionalWinmds, "additionalWinmds must round-trip declaration order intact"); } @@ -293,13 +304,13 @@ public void Load_AdditionalRefs_ParsesRelativeAndAbsolutePaths() Assert.IsNotNull(cfg.JsBindings); CollectionAssert.AreEqual( - new[] { "vendor/BigVendor.winmd", @"C:\shared\OtherCompany.SDK.winmd" }, + _arr05, cfg.JsBindings.AdditionalRefs); // Adjacent extraTypes block must still parse correctly when // additionalRefs precedes it (regression guard for parser state). Assert.AreEqual(1, cfg.JsBindings.ExtraTypes.Count); Assert.AreEqual("BigVendor.Camera", cfg.JsBindings.ExtraTypes[0].Namespace); - CollectionAssert.AreEqual(new[] { "Lens", "Sensor" }, cfg.JsBindings.ExtraTypes[0].Classes); + CollectionAssert.AreEqual(_arr04, cfg.JsBindings.ExtraTypes[0].Classes); } [TestMethod] @@ -351,10 +362,10 @@ public void SaveAndLoad_AdditionalRefs_RoundTrips() Assert.IsNotNull(roundTrip.JsBindings); // Both list fields must coexist and round-trip in their declaration order. CollectionAssert.AreEqual( - new[] { "vendor/Foo.winmd" }, + _arr03, roundTrip.JsBindings.AdditionalWinmds); CollectionAssert.AreEqual( - new[] { "vendor/BigVendor.winmd", @"C:\abs\OtherSdk.winmd" }, + _arr02, roundTrip.JsBindings.AdditionalRefs); // Extras-types adjacent block must also survive Assert.AreEqual(1, roundTrip.JsBindings.ExtraTypes.Count); @@ -403,7 +414,7 @@ public void Load_Packages_ParsesAndDedupes() Assert.IsNotNull(cfg.JsBindings); CollectionAssert.AreEqual( - new[] { "Microsoft.WindowsAppSDK.AI", "Microsoft.WindowsAppSDK" }, + _arr01, cfg.JsBindings.Packages, "Packages list must dedupe case-insensitively while preserving the first-seen casing."); } @@ -425,7 +436,7 @@ public void SaveAndLoad_Packages_RoundTrips() Assert.IsNotNull(roundTrip.JsBindings); CollectionAssert.AreEqual( - new[] { "Microsoft.WindowsAppSDK.AI" }, + _arr00, roundTrip.JsBindings.Packages); } @@ -599,7 +610,7 @@ public void Load_UnknownTopLevelFieldAfterJsBindings_IsNotAbsorbed() Assert.AreEqual("bindings/winrt", loaded.JsBindings!.Output, "Unknown top-level key must NOT overwrite jsBindings.output"); CollectionAssert.AreEqual( - new[] { "Microsoft.WindowsAppSDK.AI" }, + _arr00, loaded.JsBindings.Packages.ToList(), "Unknown top-level key's list children must NOT leak into jsBindings.packages"); } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InitCommandTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InitCommandTests.cs index 0ec11e9d..2fdd06a4 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InitCommandTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InitCommandTests.cs @@ -558,7 +558,10 @@ protected override IServiceCollection ConfigureServices(IServiceCollection servi { _fakeJsBindings = new FakeJsBindingsWorkspaceService(); var existing = services.FirstOrDefault(d => d.ServiceType == typeof(IJsBindingsWorkspaceService)); - if (existing is not null) services.Remove(existing); + if (existing is not null) + { + services.Remove(existing); + } services.AddSingleton(_fakeJsBindings); return services; } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/JsBindingsPresetsTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/JsBindingsPresetsTests.cs index 0811e398..d687e6b1 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/JsBindingsPresetsTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/JsBindingsPresetsTests.cs @@ -8,12 +8,19 @@ namespace WinApp.Cli.Tests; [TestClass] public class JsBindingsPresetsTests { + private static readonly string[] _arr00 = ["Microsoft.WindowsAppSDK.AI", "Some.Vendor.Pkg"]; + private static readonly string[] _arr01 = ["Microsoft.WindowsAppSDK.AI", "Microsoft.WindowsAppSDK.WinUI"]; + private static readonly string[] _arr02 = ["Microsoft.WindowsAppSDK.AI"]; + private static readonly string[] _arr03 = ["bogus", "ai", ""]; + private static readonly string[] _arr04 = ["ai", "AI", "ai"]; + private static readonly string[] _arr05 = ["ai"]; + [TestMethod] public void KnownPresets_AiPreset_MapsToAiPackage() { Assert.IsTrue(JsBindingsPresets.TryResolve("ai", out var packages)); CollectionAssert.AreEqual( - new[] { "Microsoft.WindowsAppSDK.AI" }, + _arr02, packages.ToList(), "AI preset must map to the Microsoft.WindowsAppSDK.AI NuGet package"); } @@ -23,7 +30,7 @@ public void KnownPresets_OnlyShipsAi() { // Only 'ai' ships today; trip this test when a new one lands. CollectionAssert.AreEqual( - new[] { "ai" }, + _arr05, JsBindingsPresets.KnownPresets.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase).ToList(), "Unexpected preset registered. If intentional, update this test and docs/js-bindings.md."); } @@ -64,9 +71,9 @@ public void ResolveAndUnion_EmptyInput_ReturnsEmpty() [TestMethod] public void ResolveAndUnion_SinglePreset_EquivalentToTryResolve() { - var result = JsBindingsPresets.ResolveAndUnion(new[] { "ai" }); + var result = JsBindingsPresets.ResolveAndUnion(_arr05); CollectionAssert.AreEqual( - new[] { "Microsoft.WindowsAppSDK.AI" }, + _arr02, result.ToList(), "Single-preset union must produce the preset's package IDs in declaration order"); } @@ -76,9 +83,9 @@ public void ResolveAndUnion_DuplicatePresets_DedupesPackageIds() { // ai listed multiple times (mixed case to also exercise the // case-insensitive comparer) — must collapse to one set. - var result = JsBindingsPresets.ResolveAndUnion(new[] { "ai", "AI", "ai" }); + var result = JsBindingsPresets.ResolveAndUnion(_arr04); CollectionAssert.AreEqual( - new[] { "Microsoft.WindowsAppSDK.AI" }, + _arr02, result.ToList(), "Repeated presets must collapse — no double package IDs"); } @@ -87,9 +94,9 @@ public void ResolveAndUnion_DuplicatePresets_DedupesPackageIds() public void ResolveAndUnion_UnknownNames_AreSkippedSilently() { // Skip unknown but keep the known one. Validation is the CLI parser's job. - var result = JsBindingsPresets.ResolveAndUnion(new[] { "bogus", "ai", "" }); + var result = JsBindingsPresets.ResolveAndUnion(_arr03); CollectionAssert.AreEqual( - new[] { "Microsoft.WindowsAppSDK.AI" }, + _arr02, result.ToList()); } @@ -334,7 +341,7 @@ public void PartitionByPackageCategory_EmitScope_OutOfScopeEmitPackages_DemotedT new FileInfo(@"C:\u\.nuget\packages\microsoft.web.webview2\1.0.0\runtimes\WebView2.winmd"), }; - var scope = new[] { "Microsoft.WindowsAppSDK.AI" }; + var scope = _arr02; var p = JsBindingsPresets.PartitionByPackageCategory( files, overrides: null, nugetCacheRoot: null, emitScope: scope); @@ -378,7 +385,7 @@ public void PartitionByPackageCategory_EmitScope_SkipCategoryWins() new FileInfo(@"C:\u\.nuget\packages\microsoft.windowsappsdk.winui\1.8\metadata\Xaml.winmd"), }; - var scope = new[] { "Microsoft.WindowsAppSDK.AI", "Microsoft.WindowsAppSDK.WinUI" }; + var scope = _arr01; var p = JsBindingsPresets.PartitionByPackageCategory( files, overrides: null, nugetCacheRoot: null, emitScope: scope); @@ -403,7 +410,7 @@ public void PartitionByPackageCategory_EmitScope_RefOnlyCategoryWins() var p = JsBindingsPresets.PartitionByPackageCategory( files, ov, nugetCacheRoot: null, - emitScope: new[] { "Microsoft.WindowsAppSDK.AI", "Some.Vendor.Pkg" }); + emitScope: _arr00); Assert.AreEqual(1, p.Emit.Count, "AI emits."); Assert.AreEqual(1, p.RefOnly.Count, "Vendor stays RefOnly via classification override."); diff --git a/src/winapp-CLI/WinApp.Cli.Tests/WinmdsLockfileServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/WinmdsLockfileServiceTests.cs index 479458f5..f87e5bcc 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/WinmdsLockfileServiceTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/WinmdsLockfileServiceTests.cs @@ -10,6 +10,8 @@ namespace WinApp.Cli.Tests; [TestClass] public class WinmdsLockfileServiceTests { + private static readonly string[] _arr00 = ["Microsoft.WindowsAppSDK.AI"]; + public TestContext TestContext { get; set; } = null!; private DirectoryInfo _temp = null!; @@ -180,7 +182,7 @@ public void BuildLockfile_PartitionFromLockfile_AppliesScopeAsEmitFilter_Demotes cache); var (emit, refOnly, skipped) = JsBindingsWorkspaceService.PartitionFromLockfile( - lockfile, new[] { "Microsoft.WindowsAppSDK.AI" }); + lockfile, _arr00); Assert.AreEqual(1, emit.Count, "Only the scoped AI package emits."); Assert.IsTrue(emit[0].FullName.EndsWith("Microsoft.Windows.AI.winmd", StringComparison.OrdinalIgnoreCase)); From 21020606e7c74ffb0c9ede6142558bed44b576a3 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Mon, 18 May 2026 15:40:10 +0800 Subject: [PATCH 03/27] fix PR comments --- .../plugin/skills/winapp-cli/setup/SKILL.md | 36 +++++++++++++++ README.md | 3 +- docs/usage.md | 44 +++++++++++++++++++ scripts/generate-llm-docs.ps1 | 26 +++++++---- src/winapp-npm/README.md | 3 +- src/winapp-npm/scripts/generate-commands.mjs | 4 +- 6 files changed, 105 insertions(+), 11 deletions(-) diff --git a/.github/plugin/skills/winapp-cli/setup/SKILL.md b/.github/plugin/skills/winapp-cli/setup/SKILL.md index 9a96b5ea..a12827ae 100644 --- a/.github/plugin/skills/winapp-cli/setup/SKILL.md +++ b/.github/plugin/skills/winapp-cli/setup/SKILL.md @@ -251,6 +251,42 @@ Creates packaged layout, registers the Application, and launches the packaged ap | `--unregister-on-exit` | Unregister the development package after the application exits. Only removes packages registered in development mode. | (none) | | `--with-alias` | Launch the app using its execution alias instead of AUMID activation. The app runs in the current terminal with inherited stdin/stdout/stderr. Requires a uap5:ExecutionAlias in the manifest. Use "winapp manifest add-alias" to add an execution alias to the manifest. | (none) | +### `winapp node jsbindings add` + +Add a jsBindings: block to winapp.yaml and run codegen. Requires winapp.yaml (run 'winapp init' first). Never modifies the packages: section or installs SDK packages — codegen runs against the workspace's already-restored packages. Refuses to clobber an existing jsBindings: block unless --force is passed. Only available when invoked via the @microsoft/winappcli npm package (npx winapp node jsbindings add). + +#### Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `` | No | Base/root directory for the winapp workspace (default: current directory) | + +#### Options + +| Option | Description | Default | +|--------|-------------|---------| +| `--ai` | Generate bindings for the 'ai' slice of the SDK only. For a custom slice that no preset covers, edit winapp.yaml's jsBindings.packages after adding. Known presets: ai. | (none) | +| `--config-dir` | Directory containing winapp.yaml (default: base-directory) | (none) | +| `--force` | Patch an existing jsBindings: block without prompting. Overwrites only output and (when a preset like --ai is supplied) the packages list; all other fields are preserved. Without --force the command refuses to clobber a pre-existing block (interactive: prompts; non-interactive: errors). | (none) | +| `--output` | Output directory for generated JS/TS bindings (relative to workspace, default 'bindings/winrt'). Persisted to winapp.yaml's jsBindings.output field. | (none) | +| `--use-defaults` | Do not prompt. When jsBindings: already exists in winapp.yaml, preserve it and exit 0 (idempotent). Use --force instead if you want the existing block patched non-interactively. | (none) | + +### `winapp node jsbindings generate` + +Re-run dynwinrt-codegen against the existing jsBindings: block in winapp.yaml. Does NOT modify the yaml — for that, use 'node jsbindings add'. Errors if no jsBindings: block is declared. Only available when invoked via the @microsoft/winappcli npm package (npx winapp node jsbindings generate). + +#### Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `` | No | Base/root directory for the winapp workspace (default: current directory) | + +#### Options + +| Option | Description | Default | +|--------|-------------|---------| +| `--config-dir` | Directory containing winapp.yaml (default: base-directory) | (none) | + ### `winapp unregister` Unregisters a sideloaded development package. Only removes packages registered in development mode (e.g., via 'winapp run' or 'create-debug-identity'). diff --git a/README.md b/README.md index 6937ffe4..005e9d1e 100644 --- a/README.md +++ b/README.md @@ -226,7 +226,8 @@ See also: [Debugging Guide](./docs/debugging.md) — choosing between `winapp ru **Node.js/Electron Specific:** -- [`node jsbindings add`](./docs/usage.md#node-add-jsbindings) - Add typed JS/TypeScript WinRT bindings to an existing workspace +- [`node jsbindings add`](./docs/usage.md#node-jsbindings-add) - Add typed JS/TypeScript WinRT bindings to an existing workspace +- [`node jsbindings generate`](./docs/usage.md#node-jsbindings-generate) - Re-run codegen against an existing `jsBindings:` block (no yaml mutation) - [`node create-addon`](./docs/usage.md#node-create-addon) - Generate native C# or C++ addons - [`node add-electron-debug-identity`](./docs/usage.md#node-add-electron-debug-identity) - Add identity to Electron processes - [`node clear-electron-debug-identity`](./docs/usage.md#node-clear-electron-debug-identity) - Remove identity from Electron processes diff --git a/docs/usage.md b/docs/usage.md index bd208c93..826b412f 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -200,6 +200,50 @@ npx winapp node jsbindings add --ai --output src/generated/winrt --- +### node jsbindings generate + +Re-run `dynwinrt-codegen` against the **existing** `jsBindings:` block in `winapp.yaml`. Does **not** mutate the yaml — for that, use `node jsbindings add`. Errors if no `jsBindings:` block is declared. npm-only — invoke as `npx winapp node jsbindings generate`. + +```bash +npx winapp node jsbindings generate [base-directory] [options] +``` + +**Arguments:** + +- `base-directory` - Workspace root containing `winapp.yaml` (default: current directory) + +**Options:** + +- `--config-dir ` - Directory containing `winapp.yaml` (default: current directory) + +**What it does:** + +- Reads the existing `jsBindings:` block from `winapp.yaml` (no mutation) +- Resolves winmds via `.winapp/winmds.lock.json` (fast path) or NuGet cache walk (fallback) +- Spawns `@microsoft/dynwinrt-codegen` to emit `.js` + `.d.ts` into the configured `jsBindings.output` directory +- Replaces the previous output dir atomically (stage-then-swap); previous bindings are preserved on codegen failure + +**When to use which command:** + +| Want to … | Command | +|---|---| +| Bootstrap a fresh workspace with bindings | `winapp init --js-bindings` (or `--js-bindings-ai`) | +| Declare `jsBindings:` on an existing workspace | `node jsbindings add` | +| Re-run codegen after editing `jsBindings:` by hand | `node jsbindings generate` | +| Re-run codegen as part of full restore (also handles NuGet + cppwinrt) | `winapp restore` | + +**Examples:** + +```bash +# Regenerate bindings against the existing winapp.yaml jsBindings: block +npx winapp node jsbindings generate + +# Regenerate from a specific workspace +npx winapp node jsbindings generate ./packages/desktop +``` + +--- + ### pack Create MSIX packages from prepared application directories. Requires a manifest file (`Package.appxmanifest` preferred, `appxmanifest.xml` also supported) to be present in the target directory, in the current directory, or passed with the `--manifest` option. (run `init` or `manifest generate` to create a manifest) diff --git a/scripts/generate-llm-docs.ps1 b/scripts/generate-llm-docs.ps1 index 167baf66..df033bb8 100644 --- a/scripts/generate-llm-docs.ps1 +++ b/scripts/generate-llm-docs.ps1 @@ -109,7 +109,7 @@ $SkillsDir = $SkillsPath # Skill → CLI command mapping for auto-generated options/arguments tables # Each skill maps to one or more CLI commands whose options/arguments should be included $SkillCommandMap = @{ - "setup" = @("init", "restore", "update", "run", "add jsbindings", "unregister") + "setup" = @("init", "restore", "update", "run", "node jsbindings add", "node jsbindings generate", "unregister") "package" = @("package", "create-external-catalog") "identity" = @("create-debug-identity") "signing" = @("cert generate", "cert install", "cert info", "sign") @@ -121,15 +121,25 @@ $SkillCommandMap = @{ # Validate that all CLI commands are covered by at least one skill $allMappedCommands = $SkillCommandMap.Values | ForEach-Object { $_ } | Where-Object { $_ } + +# Recursively enumerate all leaf command paths in the schema. +function Get-AllLeafPaths { + param([PSObject]$Node, [string]$Prefix) + + $paths = @() + if (-not $Node.subcommands) { + return @($Prefix) + } + foreach ($sub in $Node.subcommands.PSObject.Properties) { + $childPath = if ($Prefix) { "$Prefix $($sub.Name)" } else { $sub.Name } + $paths += Get-AllLeafPaths -Node $sub.Value -Prefix $childPath + } + return $paths +} + $allSchemaCommands = @() foreach ($cmd in $Schema.subcommands.PSObject.Properties) { - if ($cmd.Value.subcommands) { - foreach ($sub in $cmd.Value.subcommands.PSObject.Properties) { - $allSchemaCommands += "$($cmd.Name) $($sub.Name)" - } - } else { - $allSchemaCommands += $cmd.Name - } + $allSchemaCommands += Get-AllLeafPaths -Node $cmd.Value -Prefix $cmd.Name } $unmappedCommands = $allSchemaCommands | Where-Object { $_ -notin $allMappedCommands } if ($unmappedCommands) { diff --git a/src/winapp-npm/README.md b/src/winapp-npm/README.md index c91ba329..012aadfd 100644 --- a/src/winapp-npm/README.md +++ b/src/winapp-npm/README.md @@ -42,7 +42,8 @@ npx winapp --help - [`init`](https://github.com/microsoft/WinAppCli/blob/main/docs/usage.md#init) - Initialize project with Windows SDK and App SDK - [`restore`](https://github.com/microsoft/WinAppCli/blob/main/docs/usage.md#restore) - Restore packages and dependencies - [`update`](https://github.com/microsoft/WinAppCli/blob/main/docs/usage.md#update) - Update packages and dependencies to latest versions -- [`node jsbindings add`](https://github.com/microsoft/WinAppCli/blob/main/docs/usage.md#node-add-jsbindings) - Add typed JS/TypeScript WinRT bindings to an existing workspace +- [`node jsbindings add`](https://github.com/microsoft/WinAppCli/blob/main/docs/usage.md#node-jsbindings-add) - Add typed JS/TypeScript WinRT bindings to an existing workspace +- [`node jsbindings generate`](https://github.com/microsoft/WinAppCli/blob/main/docs/usage.md#node-jsbindings-generate) - Re-run codegen against an existing `jsBindings:` block (no yaml mutation) **App Identity & Debugging:** diff --git a/src/winapp-npm/scripts/generate-commands.mjs b/src/winapp-npm/scripts/generate-commands.mjs index ab2dd7fe..392e2fca 100644 --- a/src/winapp-npm/scripts/generate-commands.mjs +++ b/src/winapp-npm/scripts/generate-commands.mjs @@ -74,13 +74,15 @@ function kebabToPascal(s) { } // Clean up CLI description for JSDoc: single line, escape `*/` (closes the -// JSDoc) and `@` (truncates description in TS doc extractors). +// JSDoc) and `@` (truncates description in TS doc extractors). Escape `\` +// first so the escape sequences we introduce below aren't double-processed. function cleanDesc(desc) { if (!desc) return ''; return desc .replace(/\r?\n/g, ' ') .replace(/\s+/g, ' ') .trim() + .replace(/\\/g, '\\\\') .replace(/\*\//g, '*\\/') .replace(/@/g, '\\@'); } From 988746a67d1d1225465896499e45d13bd9be27af Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Mon, 18 May 2026 16:28:27 +0800 Subject: [PATCH 04/27] fix build error --- .../UpdateNotificationGatingTests.cs | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationGatingTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationGatingTests.cs index 5f3e0da5..aaa67195 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationGatingTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationGatingTests.cs @@ -58,14 +58,21 @@ public void Cleanup() try { Directory.Delete(_tempCacheDir, recursive: true); } catch { /* best effort */ } } + // Substring uniquely produced by UpdateNotificationService.DisplayUpdateNotification + // (format: "v{ver} is available. To update, …"). Looser checks like + // .Contains("available") false-positive against command descriptions + // that use the word "available" (e.g. "Only available when invoked + // via the npm package"). + private const string UpdateNoticeMarker = "is available. To update"; + [TestMethod] public async Task JsonMode_SuppressesUpdateNotice_StdoutHasNoNotice() { var (stdout, stderr, _) = await InvokeProgramAsync(["get-winapp-path", "--global", "--json"]); - Assert.IsFalse(stdout.Contains("available", StringComparison.OrdinalIgnoreCase), + Assert.IsFalse(stdout.Contains(UpdateNoticeMarker, StringComparison.OrdinalIgnoreCase), $"--json stdout must not contain update notice. Got stdout: {stdout}"); - Assert.IsFalse(stderr.Contains("available", StringComparison.OrdinalIgnoreCase), + Assert.IsFalse(stderr.Contains(UpdateNoticeMarker, StringComparison.OrdinalIgnoreCase), $"--json stderr must not contain update notice. Got stderr: {stderr}"); } @@ -74,9 +81,9 @@ public async Task QuietMode_SuppressesUpdateNotice() { var (stdout, stderr, _) = await InvokeProgramAsync(["get-winapp-path", "--global", "--quiet"]); - Assert.IsFalse(stdout.Contains("available", StringComparison.OrdinalIgnoreCase), + Assert.IsFalse(stdout.Contains(UpdateNoticeMarker, StringComparison.OrdinalIgnoreCase), $"--quiet stdout must not contain update notice. Got stdout: {stdout}"); - Assert.IsFalse(stderr.Contains("available", StringComparison.OrdinalIgnoreCase), + Assert.IsFalse(stderr.Contains(UpdateNoticeMarker, StringComparison.OrdinalIgnoreCase), $"--quiet stderr must not contain update notice. Got stderr: {stderr}"); } @@ -85,9 +92,9 @@ public async Task CliSchemaMode_SuppressesUpdateNotice() { var (stdout, stderr, _) = await InvokeProgramAsync(["--cli-schema"]); - Assert.IsFalse(stdout.Contains("available", StringComparison.OrdinalIgnoreCase), + Assert.IsFalse(stdout.Contains(UpdateNoticeMarker, StringComparison.OrdinalIgnoreCase), $"--cli-schema stdout must not contain update notice. Got stdout: {stdout}"); - Assert.IsFalse(stderr.Contains("available", StringComparison.OrdinalIgnoreCase), + Assert.IsFalse(stderr.Contains(UpdateNoticeMarker, StringComparison.OrdinalIgnoreCase), $"--cli-schema stderr must not contain update notice. Got stderr: {stderr}"); } @@ -98,9 +105,9 @@ public async Task NormalMode_ShowsUpdateNotice_OnStderr() // never stdout. We capture stderr via Console.SetError. var (stdout, stderr, _) = await InvokeProgramAsync(["get-winapp-path", "--global"]); - Assert.IsFalse(stdout.Contains("available", StringComparison.OrdinalIgnoreCase), + Assert.IsFalse(stdout.Contains(UpdateNoticeMarker, StringComparison.OrdinalIgnoreCase), $"Update notice must not appear on stdout. Got stdout: {stdout}"); - Assert.IsTrue(stderr.Contains("available", StringComparison.OrdinalIgnoreCase), + Assert.IsTrue(stderr.Contains(UpdateNoticeMarker, StringComparison.OrdinalIgnoreCase), $"Update notice should appear on stderr in normal mode. Got stderr: {stderr}"); } From 581f313290a1e47d5c79966147c57397e24e0c95 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Mon, 18 May 2026 17:08:57 +0800 Subject: [PATCH 05/27] fix tests --- samples/electron/test.Tests.ps1 | 11 ++++++----- src/winapp-npm/scripts/generate-commands.mjs | 6 ++++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/samples/electron/test.Tests.ps1 b/samples/electron/test.Tests.ps1 index 366c7a3b..e9b8019c 100644 --- a/samples/electron/test.Tests.ps1 +++ b/samples/electron/test.Tests.ps1 @@ -124,15 +124,16 @@ Describe "Electron Sample" { } # ── JS bindings smoke (v2.x) ───────────────────────────────────── - # Verify the add jsbindings --ai path end-to-end. Use --ai because - # it's the narrowest preset (~7 winmds → ~65 .js, <5s on hot cache). + # Verify the node jsbindings add --ai path end-to-end. Use --ai + # because it's the narrowest preset (~7 winmds → ~65 .js, <5s on + # hot cache). - It "Should add JS bindings via 'add jsbindings --ai'" -Skip:$script:skip { + It "Should add JS bindings via 'node jsbindings add --ai'" -Skip:$script:skip { Push-Location $script:appDir try { # winapp.cmd via npx sets WINAPP_CLI_CALLER (the command # refuses without it). - Invoke-WinappCommand -Arguments "add jsbindings --ai --force" + Invoke-WinappCommand -Arguments "node jsbindings add --ai --force" } finally { Pop-Location } } @@ -153,7 +154,7 @@ Describe "Electron Sample" { $pkgPath = Join-Path $script:appDir "package.json" $pkg = Get-Content $pkgPath -Raw | ConvertFrom-Json $pkg.dependencies.'@microsoft/dynwinrt' | Should -Not -BeNullOrEmpty ` - -Because "add jsbindings must auto-inject the runtime dep" + -Because "node jsbindings add must auto-inject the runtime dep" } It "Should write a winmds.lock.json under .winapp/" -Skip:$script:skip { diff --git a/src/winapp-npm/scripts/generate-commands.mjs b/src/winapp-npm/scripts/generate-commands.mjs index 392e2fca..f7a7f6cd 100644 --- a/src/winapp-npm/scripts/generate-commands.mjs +++ b/src/winapp-npm/scripts/generate-commands.mjs @@ -363,11 +363,13 @@ function generate(schema) { // --------------------------------------------------------------------------- const FN_NAME_OVERRIDES = { 'package': 'packageApp', // `package` is a TS reserved-ish word - 'add jsbindings': 'addJsBindings', // canonical camelCase for the compound name + 'node jsbindings add': 'nodeJsbindingsAdd', // canonical camelCase + 'node jsbindings generate': 'nodeJsbindingsGenerate', }; const IFACE_NAME_OVERRIDES = { - 'add jsbindings': 'AddJsBindingsOptions', + 'node jsbindings add': 'NodeJsbindingsAddOptions', + 'node jsbindings generate': 'NodeJsbindingsGenerateOptions', }; function getFunctionName(cmdPath) { From 89cc5eed2b1328a1cb7346d89255e6783b977e0f Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Mon, 18 May 2026 21:23:31 +0800 Subject: [PATCH 06/27] add electron guide --- docs/guides/electron/jsbindings.md | 177 +++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 docs/guides/electron/jsbindings.md diff --git a/docs/guides/electron/jsbindings.md b/docs/guides/electron/jsbindings.md new file mode 100644 index 00000000..6f8c46e0 --- /dev/null +++ b/docs/guides/electron/jsbindings.md @@ -0,0 +1,177 @@ + +# Calling WinRT APIs from JavaScript (JS / TypeScript bindings) + +This guide shows you how to call modern Windows Runtime (WinRT) APIs directly from your Electron app's JavaScript or TypeScript — **without** writing a C++ or C# native addon. `winapp` integrates the [`@microsoft/dynwinrt-codegen`](https://www.npmjs.com/package/@microsoft/dynwinrt-codegen) codegen, which produces typed JS + `.d.ts` bindings for WinAppSDK (and any other WinRT) APIs from their `.winmd` metadata. The generated bindings then use [`@microsoft/dynwinrt`](https://www.npmjs.com/package/@microsoft/dynwinrt) to access the underlying WinRT APIs directly at runtime. The result: full IntelliSense at compile time, no `node-gyp` / MSBuild step from your Electron project. + +> **When to choose JS bindings over a native addon:** when you only need to *call* WinRT APIs (load a model, run inference, send a notification, read a sensor) and don't need a stateful C++/C# service or APIs `dynwinrt` doesn't yet drive (XAML, DispatcherQueue). For data-style WinRT APIs, JS bindings are the easier on-ramp; for stateful or UI-hosting scenarios, see the C++ / C# addon guides. + +## Prerequisites + +Before starting this guide, make sure you've: +- Completed the [development environment setup](setup.md) +- Used `winapp` via `npx` (i.e., the `@microsoft/winappcli` npm package) — JS bindings are gated to npm-invoked `winapp` because the generator (`@microsoft/dynwinrt-codegen`) and runtime (`@microsoft/dynwinrt`) ship as npm dependencies. A winget / standalone install of `winapp.exe` will reject `--js-bindings*` and `node jsbindings add` with a clear error. + +## Step 1: Add JS bindings to your project + +You have two paths depending on whether your Electron app already has a `winapp.yaml`. + +### Path A — Fresh project (init with bindings) + +If you're setting up `winapp` for the first time, ask `init` to wire bindings in the same step. The `--js-bindings-ai` preset narrows generation to the Windows AI surface (`Microsoft.WindowsAppSDK.AI`): + +```bash +npx winapp init --use-defaults --js-bindings-ai +npm install +``` + +`init` installs the AI NuGet package, writes a `winapp.yaml` with a `jsBindings:` block, and runs the codegen. `npm install` picks up the `@microsoft/dynwinrt` runtime dependency that `init` injected into your `package.json`. + +For the full WinAppSDK surface (every namespace), drop the preset: + +```bash +npx winapp init --js-bindings +``` + +### Path B — Existing project (layer bindings on) + +If `winapp.yaml` already exists and you don't want to re-run the full `init` pipeline, use the layered `node jsbindings add` sub-command. It edits **only** the `jsBindings:` block, never re-restores SDK packages, and runs codegen against your already-restored winmds: + +```bash +npx winapp node jsbindings add --ai +``` + +If `jsBindings:` already exists and you want to overwrite, pass `--force`: + +```bash +npx winapp node jsbindings add --ai --force +``` + +### What you get + +Both paths produce a `bindings/winrt/` directory next to your sources: + +``` +bindings/winrt/ +├── index.js # entry — re-exports every emitted class +├── index.d.ts # TS bundle +├── Microsoft.Windows.AI.Generative.LanguageModel.js +├── Microsoft.Windows.AI.Generative.LanguageModel.d.ts +└── … # one pair of files per emitted class +``` + +To put them somewhere else, pass `--js-bindings-output PATH` (for `init`) or `--output PATH` (for `node jsbindings add`). + +> [!NOTE] +> If you need to slice generation by NuGet package, add your own `.winmd`, or cherry-pick a few classes from a giant vendor SDK, see the recipes in [JS / TypeScript bindings for WinRT](../../js-bindings.md). This guide sticks to the simplest preset-driven flow. + +## Step 2: Call a WinRT API from your Electron code + +Import from the generated `index.js` — you don't need to know which file inside `bindings/winrt/` a class lives in. Here's the full Phi Silica text-generation flow as it would run in your Electron main process: + +```js +// src/index.js (Electron main) +const { + LanguageModel, + LanguageModelOptions, + LimitedAccessFeatures, + LimitedAccessFeatureStatus, +} = require('./bindings/winrt/index.js'); + +async function generateText(prompt, onProgress) { + // Phi Silica is a Limited Access Feature — unlock it first. + const access = LimitedAccessFeatures.tryUnlockFeature( + 'com.microsoft.windows.ai.languagemodel', + process.env.LAF_TOKEN, + 'See the LAF docs at https://learn.microsoft.com/windows/apps/develop/limited-access-features' + ); + if (access.status !== LimitedAccessFeatureStatus.Available && + access.status !== LimitedAccessFeatureStatus.AvailableWithoutToken) { + throw new Error('Phi Silica not available on this device.'); + } + + const languageModel = await LanguageModel.createAsync(); + try { + const options = LanguageModelOptions.create(); + options.temperature = 0.9; + options.topK = 15; + options.topP = 0.8; + + const op = languageModel.generateResponseAsync(prompt, options); + op.progress((value) => onProgress?.(value)); + const result = await op; + return result.text; + } finally { + languageModel.close(); + } +} +``` + +A few conventions to remember: + +- **Method names are camelCase.** WinRT methods like `GenerateResponseAsync` become `generateResponseAsync`; properties like `result.Text` become `result.text`. The codegen lowercases the first letter to match JavaScript style. +- **Structs use a `create()` factory, not `new`.** `LanguageModelOptions.create()` — not `new LanguageModelOptions()`. +- **Async methods return a `progressOperation` thenable.** It's both `await`-able and exposes `op.progress(cb)` for streaming progress updates. +- **Always `close()` IDisposable WinRT objects** in a `try/finally`. This frees the underlying COM resources promptly. +- **Pass `AbortSignal` for cancellation** when the underlying API supports it: `LanguageModel.createAsync(signal)`, `op = languageModel.generateResponseAsync(prompt, options, signal)`. Calling `controller.abort()` releases the awaiting Promise. + +You can call the same API from the renderer via `contextBridge` / `ipcRenderer` — exactly as you would for a native addon. The bindings have no dependency on Electron's main process; they work anywhere Node.js can `require()` them. + +## Step 3: Run it + +WinRT APIs that require an MSIX package identity (notifications, file pickers, …) need debug identity in development. If you haven't already, set it up: + +```bash +npx winapp node add-electron-debug-identity +``` + +> [!NOTE] +> This is already part of the `postinstall` script added during setup, so it usually runs automatically on `npm install`. Re-run it manually whenever you change `Package.appxmanifest`, refresh app assets, or do a clean install. + +Now start the app: + +```bash +npm start +``` + +The first call to a WinRT method imported from `bindings/winrt/` will load `@microsoft/dynwinrt`, resolve the `.winmd` metadata, and invoke the COM method via libffi — all transparent to your code. + +## Step 4 (optional): Regenerate after a metadata change + +The generated `bindings/winrt/` files are committed-or-gitignored at your discretion (treat them like `package-lock.json` — generated, but stable enough to commit if you want diff visibility). Regenerate whenever: + +- You bump a WinAppSDK / WinRT package version in `winapp.yaml` +- You add or remove entries in `additionalWinmds:` / `extraTypes:` +- The codegen itself is upgraded (`npm update @microsoft/dynwinrt-codegen`) + +To regenerate without changing the `jsBindings:` configuration: + +```bash +npx winapp restore # regenerates bindings as part of the standard restore step +``` + +Or, to replace the block and regenerate from scratch: + +```bash +npx winapp node jsbindings add --ai --force +``` + +## Troubleshooting + +**`Cannot find module './bindings/winrt'`** +The generator hasn't produced output yet. Re-run `npx winapp restore` (or `node jsbindings add`) and verify `bindings/winrt/index.js` exists. + +**`MissingMethodException` / `Type not registered`** +A class your code imports is in a `.winmd` that isn't on the codegen's input. Check the `packages:` list (or `additionalWinmds:`) in `winapp.yaml` — empty/omitted `jsBindings.packages` means "all installed packages participate", but if you've curated the list make sure the relevant package is there. + +**`HRESULT 0x8007XXXX` at call time** +The metadata was emitted but the OS implementation isn't available — usually a missing OS feature (e.g., Phi Silica on a non-Copilot+ PC) or missing capability declaration in `Package.appxmanifest`. The exception message preserves the WinRT error string from the COM layer. + +**Bindings work in development but not after `electron-packager` / `electron-builder`** +Make sure `@microsoft/dynwinrt` is in your runtime `dependencies` (not just `devDependencies`) and that the packager's `asarUnpack` rules include the native binary. See [`packaging.md`](packaging.md) for the recommended config. + +## Next steps + +- **Reference** — [JS / TypeScript bindings for WinRT (`jsBindings`)](../../js-bindings.md) for the full `winapp.yaml` schema, every CLI flag, and advanced recipes (slice by package, cherry-pick types, ship a vendor `.winmd`). +- **CLI** — [`npx winapp node jsbindings add` reference](../../usage.md#node-jsbindings-add) and [`npx winapp init` reference](../../usage.md#init). +- **Runtime** — [`@microsoft/dynwinrt` on GitHub](https://github.com/microsoft/dynwinrt) for the libffi-based runtime that powers the generated bindings. +- **Package & ship** — [Packaging Your App](packaging.md) once you're ready to produce an MSIX for distribution. From a142c35ae1aa9d866620a455c68912ddffb302aa Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Tue, 19 May 2026 23:28:24 +0800 Subject: [PATCH 07/27] address all comments --- .github/plugin/agents/winapp.agent.md | 9 +- .../skills/winapp-cli/frameworks/SKILL.md | 22 +- .../plugin/skills/winapp-cli/setup/SKILL.md | 4 + .../fragments/skills/winapp-cli/frameworks.md | 22 +- docs/fragments/skills/winapp-cli/setup.md | 4 + docs/guides/electron/jsbindings.md | 64 +- samples/electron/test.Tests.ps1 | 45 +- .../AddJsBindingsOrchestrationTests.cs | 345 +++++-- .../ConfigServiceJsBindingsTests.cs | 102 ++ .../DynWinrtCodegenArgvTests.cs | 5 +- .../DynWinrtCodegenInvocationTests.cs | 118 ++- .../GenerateJsBindingsCommandTests.cs | 24 + .../WinApp.Cli.Tests/PathSafetyTests.cs | 328 +++++++ .../FakeJsBindingsWorkspaceService.cs | 10 +- .../UserPackageJsonServiceTests.cs | 133 +++ .../WinappConfigDocumentTests.cs | 497 ++++++++++ .../WinmdsLockfileServiceTests.cs | 190 +++- .../WorkspaceSetupServiceTests.cs | 116 +++ .../WinApp.Cli/Commands/InitCommand.cs | 2 +- .../WinApp.Cli/Helpers/PathSafety.cs | 240 +++++ .../WinApp.Cli/Services/ConfigService.cs | 541 +---------- .../Services/DynWinrtCodegenService.cs | 157 +-- ...dingsWorkspaceService.RuntimeDependency.cs | 74 ++ ...BindingsWorkspaceService.WinmdDiscovery.cs | 219 +++++ .../Services/JsBindingsWorkspaceService.cs | 264 +---- .../Services/UserPackageJsonService.cs | 54 +- .../Services/WinappConfigDocument.cs | 696 +++++++++++++ .../Services/WinmdsLockfileService.cs | 38 +- .../Services/WorkspaceSetupService.Init.cs | 268 ++++++ .../Services/WorkspaceSetupService.Msix.cs | 298 ++++++ .../Services/WorkspaceSetupService.Options.cs | 59 ++ .../Services/WorkspaceSetupService.Prompts.cs | 240 +++++ .../Services/WorkspaceSetupService.cs | 911 ++---------------- src/winapp-VSC/README.md | 2 + src/winapp-npm/src/cli.ts | 26 +- 35 files changed, 4283 insertions(+), 1844 deletions(-) create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/PathSafetyTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/WinappConfigDocumentTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Helpers/PathSafety.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/JsBindingsWorkspaceService.RuntimeDependency.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/JsBindingsWorkspaceService.WinmdDiscovery.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/WinappConfigDocument.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Init.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Msix.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Options.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Prompts.cs diff --git a/.github/plugin/agents/winapp.agent.md b/.github/plugin/agents/winapp.agent.md index 23899511..215f4dee 100644 --- a/.github/plugin/agents/winapp.agent.md +++ b/.github/plugin/agents/winapp.agent.md @@ -106,12 +106,19 @@ Want to inspect or interact with a running app's UI? **Purpose:** Layer typed JS/TS WinRT bindings (via `@microsoft/dynwinrt-codegen`) onto an existing workspace. **When to use:** After `winapp init` on a Node/Electron host, when you want callable WinRT APIs without a native build step. **Key options:** -- `--ai` — limit generation to the `Microsoft.WindowsAppSDK.AI` slice (the only ships-today preset) +- `--ai` — limit generation to the AI surface — the `Microsoft.WindowsAppSDK.AI` NuGet package, which projects the `Microsoft.Windows.AI.*` namespaces (the only ships-today preset) - `--output PATH` — output directory (default `bindings/winrt`); persisted to `winapp.yaml` - `--force` — patch an existing `jsBindings:` block (overwrites `output` and preset packages; preserves user customisations like `extraTypes` / `additionalWinmds` / `skipPackages`) - `--config-dir` — directory containing `winapp.yaml` (default: `base-directory`) **Requires:** `winapp.yaml` already exists; npm-only (run as `npx winapp node jsbindings add`). Never modifies `packages:` or installs SDK packages. +### `winapp node jsbindings generate` (alias: `winapp node js-bindings generate`) +**Purpose:** Re-run codegen for the existing `jsBindings:` block in `winapp.yaml` without mutating config. +**When to use:** After editing `jsBindings.packages` / `extraTypes` / `additionalWinmds` by hand, after pulling teammates' `winapp.yaml`, or after a `restore` that brought new SDK versions in. +**Key options:** +- `--config-dir` — directory containing `winapp.yaml` (default: `base-directory`) +**Requires:** `winapp.yaml` already has a `jsBindings:` block; npm-only (run as `npx winapp node jsbindings generate`). Does not edit `winapp.yaml` or `package.json`. + ### `winapp package ` (alias: `winapp pack`) **Purpose:** Create an MSIX installer from a built app. **When to use:** After building your app, when you want to create a distributable MSIX package. diff --git a/.github/plugin/skills/winapp-cli/frameworks/SKILL.md b/.github/plugin/skills/winapp-cli/frameworks/SKILL.md index 6e489a0c..0440d822 100644 --- a/.github/plugin/skills/winapp-cli/frameworks/SKILL.md +++ b/.github/plugin/skills/winapp-cli/frameworks/SKILL.md @@ -37,13 +37,29 @@ Quick start: npm install --save-dev @microsoft/winappcli npx winapp init --use-defaults --js-bindings-ai # init + generate typed AI bindings in bindings/winrt/ # (or, if you already initialized the workspace:) -npx winapp node jsbindings add --ai # layer JS bindings onto an existing workspace -npx winapp node create-addon --template cs # create a C# native addon (for stuff dynwinrt can't drive) -npx winapp node add-electron-debug-identity # register identity for debugging +npx winapp node jsbindings add --ai # layer JS bindings onto an existing workspace +npx winapp node create-addon --template cs # create a C# native addon (for what dynwinrt can't drive — see below) +npx winapp node add-electron-debug-identity # register identity for debugging ``` The `--js-bindings*` flags (and the `node jsbindings add` sub-command) are **npm-only** — they require invocation via the `@microsoft/winappcli` npm package because they pull in `@microsoft/dynwinrt-codegen` as a transitive dep. The winget / standalone install will reject these surfaces with a clear error. +#### Choosing between jsBindings and a native addon + +The decision is almost entirely about the **shape of the API**, not preference. + +**Default: if the API is WinRT (ships in a `.winmd`), use `node jsbindings add`.** That covers nearly everything an Electron app actually calls on Windows — `Microsoft.Windows.*` (Notifications, FilePickers, Sensors, Storage, AI inference like `TextRecognizer` / `LanguageModel`), most of `Windows.*`, and all of `Microsoft.WindowsAppSDK.AI`. dynwinrt's [own scope statement](https://github.com/microsoft/dynwinrt#scope) sums it up as "non-UI WinRT APIs ... headless services from the Windows SDK and WinAppSDK". + +**Fall back to `node create-addon` when one of these is true:** + +| Scenario | Template | Why dynwinrt can't help | +|---|---|---| +| The API is **Win32 / pure COM with no WinRT projection** (P/Invoke-style APIs, raw `IFileDialog`, registry, custom COM servers). | `--template cpp` | No `.winmd` exists, so there's nothing for the codegen to project. | +| You're integrating a **C++ library that ships only headers + a static/shared lib** (no `.winmd`). | `--template cpp` | Same — dynwinrt requires WinRT metadata. | +| You're integrating a **vendor SDK that only ships a managed .NET assembly** (no `.winmd`). | `--template cs` (uses [node-api-dotnet](https://github.com/microsoft/node-api-dotnet) under the hood). | Same — no WinRT projection to consume. | + +It's normal to mix both in one app: jsBindings for the non-UI WinRT surface, a small C# or C++ addon for the one or two Win32 / non-WinRT calls that don't fit. + Additional Electron guides: - [JS bindings reference](https://github.com/microsoft/WinAppCli/blob/main/docs/js-bindings.md) — full `jsBindings:` yaml schema, presets, per-package classification, lockfile - [Packaging guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/packaging.md) diff --git a/.github/plugin/skills/winapp-cli/setup/SKILL.md b/.github/plugin/skills/winapp-cli/setup/SKILL.md index a12827ae..37a63217 100644 --- a/.github/plugin/skills/winapp-cli/setup/SKILL.md +++ b/.github/plugin/skills/winapp-cli/setup/SKILL.md @@ -63,6 +63,10 @@ npx winapp init --use-defaults --js-bindings-ai # Or layer bindings onto an already-initialized workspace. npx winapp node jsbindings add --ai + +# After editing winapp.yaml jsBindings: by hand (or pulling a teammate's +# winapp.yaml), regenerate bindings without re-prompting: +npx winapp node jsbindings generate ``` Generated files land under `bindings/winrt/` and `@microsoft/dynwinrt` is diff --git a/docs/fragments/skills/winapp-cli/frameworks.md b/docs/fragments/skills/winapp-cli/frameworks.md index e94ee8f6..dfcc1f11 100644 --- a/docs/fragments/skills/winapp-cli/frameworks.md +++ b/docs/fragments/skills/winapp-cli/frameworks.md @@ -32,13 +32,29 @@ Quick start: npm install --save-dev @microsoft/winappcli npx winapp init --use-defaults --js-bindings-ai # init + generate typed AI bindings in bindings/winrt/ # (or, if you already initialized the workspace:) -npx winapp node jsbindings add --ai # layer JS bindings onto an existing workspace -npx winapp node create-addon --template cs # create a C# native addon (for stuff dynwinrt can't drive) -npx winapp node add-electron-debug-identity # register identity for debugging +npx winapp node jsbindings add --ai # layer JS bindings onto an existing workspace +npx winapp node create-addon --template cs # create a C# native addon (for what dynwinrt can't drive — see below) +npx winapp node add-electron-debug-identity # register identity for debugging ``` The `--js-bindings*` flags (and the `node jsbindings add` sub-command) are **npm-only** — they require invocation via the `@microsoft/winappcli` npm package because they pull in `@microsoft/dynwinrt-codegen` as a transitive dep. The winget / standalone install will reject these surfaces with a clear error. +#### Choosing between jsBindings and a native addon + +The decision is almost entirely about the **shape of the API**, not preference. + +**Default: if the API is WinRT (ships in a `.winmd`), use `node jsbindings add`.** That covers nearly everything an Electron app actually calls on Windows — `Microsoft.Windows.*` (Notifications, FilePickers, Sensors, Storage, AI inference like `TextRecognizer` / `LanguageModel`), most of `Windows.*`, and all of `Microsoft.WindowsAppSDK.AI`. dynwinrt's [own scope statement](https://github.com/microsoft/dynwinrt#scope) sums it up as "non-UI WinRT APIs ... headless services from the Windows SDK and WinAppSDK". + +**Fall back to `node create-addon` when one of these is true:** + +| Scenario | Template | Why dynwinrt can't help | +|---|---|---| +| The API is **Win32 / pure COM with no WinRT projection** (P/Invoke-style APIs, raw `IFileDialog`, registry, custom COM servers). | `--template cpp` | No `.winmd` exists, so there's nothing for the codegen to project. | +| You're integrating a **C++ library that ships only headers + a static/shared lib** (no `.winmd`). | `--template cpp` | Same — dynwinrt requires WinRT metadata. | +| You're integrating a **vendor SDK that only ships a managed .NET assembly** (no `.winmd`). | `--template cs` (uses [node-api-dotnet](https://github.com/microsoft/node-api-dotnet) under the hood). | Same — no WinRT projection to consume. | + +It's normal to mix both in one app: jsBindings for the non-UI WinRT surface, a small C# or C++ addon for the one or two Win32 / non-WinRT calls that don't fit. + Additional Electron guides: - [JS bindings reference](https://github.com/microsoft/WinAppCli/blob/main/docs/js-bindings.md) — full `jsBindings:` yaml schema, presets, per-package classification, lockfile - [Packaging guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/packaging.md) diff --git a/docs/fragments/skills/winapp-cli/setup.md b/docs/fragments/skills/winapp-cli/setup.md index 27bd1748..f10042e9 100644 --- a/docs/fragments/skills/winapp-cli/setup.md +++ b/docs/fragments/skills/winapp-cli/setup.md @@ -58,6 +58,10 @@ npx winapp init --use-defaults --js-bindings-ai # Or layer bindings onto an already-initialized workspace. npx winapp node jsbindings add --ai + +# After editing winapp.yaml jsBindings: by hand (or pulling a teammate's +# winapp.yaml), regenerate bindings without re-prompting: +npx winapp node jsbindings generate ``` Generated files land under `bindings/winrt/` and `@microsoft/dynwinrt` is diff --git a/docs/guides/electron/jsbindings.md b/docs/guides/electron/jsbindings.md index 6f8c46e0..fab5f7c9 100644 --- a/docs/guides/electron/jsbindings.md +++ b/docs/guides/electron/jsbindings.md @@ -17,7 +17,7 @@ You have two paths depending on whether your Electron app already has a `winapp. ### Path A — Fresh project (init with bindings) -If you're setting up `winapp` for the first time, ask `init` to wire bindings in the same step. The `--js-bindings-ai` preset narrows generation to the Windows AI surface (`Microsoft.WindowsAppSDK.AI`): +If you're setting up `winapp` for the first time, ask `init` to wire bindings in the same step. The `--js-bindings-ai` preset narrows generation to the Windows AI surface — the [`Microsoft.WindowsAppSDK.AI`](https://www.nuget.org/packages/Microsoft.WindowsAppSDK.AI) NuGet package, which projects the `Microsoft.Windows.AI.*` namespaces: ```bash npx winapp init --use-defaults --js-bindings-ai @@ -54,6 +54,8 @@ Both paths produce a `bindings/winrt/` directory next to your sources: bindings/winrt/ ├── index.js # entry — re-exports every emitted class ├── index.d.ts # TS bundle +├── Microsoft.Windows.Vision.TextRecognizer.js +├── Microsoft.Windows.Vision.TextRecognizer.d.ts ├── Microsoft.Windows.AI.Generative.LanguageModel.js ├── Microsoft.Windows.AI.Generative.LanguageModel.d.ts └── … # one pair of files per emitted class @@ -66,59 +68,55 @@ To put them somewhere else, pass `--js-bindings-output PATH` (for `init`) or `-- ## Step 2: Call a WinRT API from your Electron code -Import from the generated `index.js` — you don't need to know which file inside `bindings/winrt/` a class lives in. Here's the full Phi Silica text-generation flow as it would run in your Electron main process: +Import from the generated `index.js` — you don't need to know which file inside `bindings/winrt/` a class lives in. Here's an OCR (text recognition) flow as it would run in your Electron main process. We use `TextRecognizer` rather than `LanguageModel` because it doesn't require a Limited Access Feature token, so you can run this end-to-end on any Copilot+ PC without applying for access: ```js // src/index.js (Electron main) +const path = require('path'); const { - LanguageModel, - LanguageModelOptions, - LimitedAccessFeatures, - LimitedAccessFeatureStatus, + TextRecognizer, + AIFeatureReadyState, } = require('./bindings/winrt/index.js'); -async function generateText(prompt, onProgress) { - // Phi Silica is a Limited Access Feature — unlock it first. - const access = LimitedAccessFeatures.tryUnlockFeature( - 'com.microsoft.windows.ai.languagemodel', - process.env.LAF_TOKEN, - 'See the LAF docs at https://learn.microsoft.com/windows/apps/develop/limited-access-features' - ); - if (access.status !== LimitedAccessFeatureStatus.Available && - access.status !== LimitedAccessFeatureStatus.AvailableWithoutToken) { - throw new Error('Phi Silica not available on this device.'); +async function recognizeText(imagePath) { + // First-run model download (one time per user) — cheap no-op once cached. + if (TextRecognizer.getReadyState() !== AIFeatureReadyState.ready) { + await TextRecognizer.ensureReadyAsync(); } - const languageModel = await LanguageModel.createAsync(); + const recognizer = await TextRecognizer.createAsync(); try { - const options = LanguageModelOptions.create(); - options.temperature = 0.9; - options.topK = 15; - options.topP = 0.8; - - const op = languageModel.generateResponseAsync(prompt, options); - op.progress((value) => onProgress?.(value)); - const result = await op; - return result.text; + const recognized = await recognizer.recognizeTextFromImageAsync(imagePath); + return recognized.lines.map(line => ({ + text: line.text, + x: line.boundingBox.topLeft.x, + y: line.boundingBox.topLeft.y, + })); } finally { - languageModel.close(); + recognizer.close(); } } + +// Usage: +// const lines = await recognizeText(path.join(__dirname, 'screenshot.png')); +// lines.forEach(l => console.log(`(${l.x}, ${l.y}): ${l.text}`)); ``` +For the full text-generation (Phi Silica `LanguageModel`) flow — which also lives in the same `bindings/winrt/` output — see the [Windows AI APIs reference](https://learn.microsoft.com/windows/ai/apis/). That surface requires a [Limited Access Feature token](https://learn.microsoft.com/windows/apps/develop/limited-access-features) before `LanguageModel.createAsync()` will succeed. + A few conventions to remember: -- **Method names are camelCase.** WinRT methods like `GenerateResponseAsync` become `generateResponseAsync`; properties like `result.Text` become `result.text`. The codegen lowercases the first letter to match JavaScript style. -- **Structs use a `create()` factory, not `new`.** `LanguageModelOptions.create()` — not `new LanguageModelOptions()`. -- **Async methods return a `progressOperation` thenable.** It's both `await`-able and exposes `op.progress(cb)` for streaming progress updates. +- **Method names are camelCase.** WinRT methods like `RecognizeTextFromImageAsync` become `recognizeTextFromImageAsync`; properties like `line.Text` become `line.text`. The codegen lowercases the first letter to match JavaScript style. +- **Structs use a `create()` factory, not `new`.** For example, `LanguageModelOptions.create()` — not `new LanguageModelOptions()`. +- **Async methods return a `progressOperation` thenable.** It's both `await`-able and exposes `op.progress(cb)` for streaming progress updates (e.g., `LanguageModel.generateResponseAsync` token streams). - **Always `close()` IDisposable WinRT objects** in a `try/finally`. This frees the underlying COM resources promptly. -- **Pass `AbortSignal` for cancellation** when the underlying API supports it: `LanguageModel.createAsync(signal)`, `op = languageModel.generateResponseAsync(prompt, options, signal)`. Calling `controller.abort()` releases the awaiting Promise. +- **Pass `AbortSignal` for cancellation** when the underlying API supports it: `recognizer.recognizeTextFromImageAsync(imagePath, signal)`, `LanguageModel.createAsync(signal)`. Calling `controller.abort()` releases the awaiting Promise. You can call the same API from the renderer via `contextBridge` / `ipcRenderer` — exactly as you would for a native addon. The bindings have no dependency on Electron's main process; they work anywhere Node.js can `require()` them. ## Step 3: Run it -WinRT APIs that require an MSIX package identity (notifications, file pickers, …) need debug identity in development. If you haven't already, set it up: +WinRT APIs that require an MSIX package identity (notifications, file pickers, …) need debug identity in development. See [Step 5 of the Electron setup guide](setup.md#step-5-understanding-debug-identity) for the full explanation; if you haven't already wired it up, the one-shot command is: ```bash npx winapp node add-electron-debug-identity @@ -164,7 +162,7 @@ The generator hasn't produced output yet. Re-run `npx winapp restore` (or `node A class your code imports is in a `.winmd` that isn't on the codegen's input. Check the `packages:` list (or `additionalWinmds:`) in `winapp.yaml` — empty/omitted `jsBindings.packages` means "all installed packages participate", but if you've curated the list make sure the relevant package is there. **`HRESULT 0x8007XXXX` at call time** -The metadata was emitted but the OS implementation isn't available — usually a missing OS feature (e.g., Phi Silica on a non-Copilot+ PC) or missing capability declaration in `Package.appxmanifest`. The exception message preserves the WinRT error string from the COM layer. +The metadata was emitted but the OS implementation isn't available — usually a missing OS feature (e.g., a Windows AI API on a non-Copilot+ PC) or missing capability declaration in `Package.appxmanifest`. The exception message preserves the WinRT error string from the COM layer. **Bindings work in development but not after `electron-packager` / `electron-builder`** Make sure `@microsoft/dynwinrt` is in your runtime `dependencies` (not just `devDependencies`) and that the packager's `asarUnpack` rules include the native binary. See [`packaging.md`](packaging.md) for the recommended config. diff --git a/samples/electron/test.Tests.ps1 b/samples/electron/test.Tests.ps1 index e9b8019c..ededa721 100644 --- a/samples/electron/test.Tests.ps1 +++ b/samples/electron/test.Tests.ps1 @@ -110,10 +110,15 @@ Describe "Electron Sample" { } finally { Pop-Location } } - It "Should initialize winapp workspace" -Skip:$script:skip { + It "Should initialize winapp workspace with JS bindings (AI preset)" -Skip:$script:skip { + # `init --js-bindings-ai` is the fresh-project shortcut: it + # bootstraps the workspace AND runs codegen in one step, so we + # use it here in place of two separate calls (init + add). The + # AI preset is the narrowest (~7 winmds → ~65 .js, <5s on hot + # cache) and is the only ships-today preset. Push-Location $script:appDir try { - Invoke-WinappCommand -Arguments "init . --use-defaults --setup-sdks=stable" + Invoke-WinappCommand -Arguments "init . --use-defaults --setup-sdks=stable --js-bindings-ai" } finally { Pop-Location } } @@ -124,18 +129,10 @@ Describe "Electron Sample" { } # ── JS bindings smoke (v2.x) ───────────────────────────────────── - # Verify the node jsbindings add --ai path end-to-end. Use --ai - # because it's the narrowest preset (~7 winmds → ~65 .js, <5s on - # hot cache). - - It "Should add JS bindings via 'node jsbindings add --ai'" -Skip:$script:skip { - Push-Location $script:appDir - try { - # winapp.cmd via npx sets WINAPP_CLI_CALLER (the command - # refuses without it). - Invoke-WinappCommand -Arguments "node jsbindings add --ai --force" - } finally { Pop-Location } - } + # Verify the init --js-bindings-ai path produced the expected + # bindings output, lockfile, and runtime dep — and that the + # read-only `generate` re-run path still works against the + # already-mutated workspace. It "Should have generated bindings/winrt/ with the managed marker" -Skip:$script:skip { $bindingsDir = Join-Path $script:appDir "bindings\winrt" @@ -154,7 +151,7 @@ Describe "Electron Sample" { $pkgPath = Join-Path $script:appDir "package.json" $pkg = Get-Content $pkgPath -Raw | ConvertFrom-Json $pkg.dependencies.'@microsoft/dynwinrt' | Should -Not -BeNullOrEmpty ` - -Because "node jsbindings add must auto-inject the runtime dep" + -Because "init --js-bindings-ai must auto-inject the runtime dep" } It "Should write a winmds.lock.json under .winapp/" -Skip:$script:skip { @@ -166,6 +163,24 @@ Describe "Electron Sample" { $lockfile.packages | Should -Not -BeNullOrEmpty -Because "Lockfile should record discovered packages" } + It "Should re-run codegen via 'node jsbindings generate' without mutating winapp.yaml" -Skip:$script:skip { + # `generate` is the read-only regen path — it must not modify + # winapp.yaml. Capture the yaml hash before/after to prove it. + $yamlPath = Join-Path $script:appDir "winapp.yaml" + $bindingsDir = Join-Path $script:appDir "bindings\winrt" + $hashBefore = (Get-FileHash -Path $yamlPath -Algorithm SHA256).Hash + + Push-Location $script:appDir + try { + Invoke-WinappCommand -Arguments "node jsbindings generate" + } finally { Pop-Location } + + $hashAfter = (Get-FileHash -Path $yamlPath -Algorithm SHA256).Hash + $hashAfter | Should -Be $hashBefore -Because "generate must not mutate winapp.yaml" + (Join-Path $bindingsDir ".dynwinrt-managed") | Should -Exist ` + -Because "generate should leave the managed marker in place after regen" + } + It "Should create a C++ native addon" -Skip:$script:skip { Push-Location $script:appDir try { diff --git a/src/winapp-CLI/WinApp.Cli.Tests/AddJsBindingsOrchestrationTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/AddJsBindingsOrchestrationTests.cs index 5aa44ab1..2c87d664 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/AddJsBindingsOrchestrationTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/AddJsBindingsOrchestrationTests.cs @@ -10,9 +10,9 @@ namespace WinApp.Cli.Tests; -// Hermetic orchestration tests for AddJsBindingsAsync. FakeDynWinrtCodegenService -// is injected so the codegen executable is never spawned. Fast-path vs fallback -// is driven by writing (or omitting) winmds.lock.json under .winapp/. +// Hermetic orchestration tests for AddJsBindingsAsync. Injects a fake +// codegen so the executable is never spawned. Fast-path vs fallback is +// driven by writing (or omitting) .winapp/winmds.lock.json. // [DoNotParallelize] because the tests mutate WINAPP_CLI_CALLER. [TestClass] [DoNotParallelize] @@ -38,9 +38,7 @@ protected override IServiceCollection ConfigureServices(IServiceCollection servi [TestInitialize] public void SetNpmCallerEnv() { - // AddJsBindingsCommand gates behind this exact env value (matches the - // const NpmShimCaller in the command's handler). Tests in this class - // simulate npm-wrapper invocation throughout. + // AddJsBindingsCommand gates on this exact env value (NpmShimCaller). Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); } @@ -59,8 +57,8 @@ private DirectoryInfo SetUpWorkspaceWithLockfile( var winappDir = ws.CreateSubdirectory(".winapp"); - // Must match the hash AddJsBindingsAsync will compute, or the - // fast-path rejects the lockfile as stale. + // Match the hash AddJsBindingsAsync computes; otherwise fast-path + // rejects as stale. var loadedConfig = new ConfigService(new CurrentDirectoryProvider(ws.FullName)) { ConfigPath = new FileInfo(Path.Combine(ws.FullName, "winapp.yaml")), @@ -110,9 +108,8 @@ private DirectoryInfo SetUpWorkspaceWithLockfile( [TestMethod] public async Task AddJsBindings_HappyPath_ExitsZero_GeneratesBindings_InjectsRuntimeDep() { - // Realistic scenario: workspace has a lockfile with one AI package + - // its winmds. fast-path partitions, calls codegen (which we fake to - // succeed), and add jsbindings exits 0. + // Workspace has a lockfile with one AI package + its winmds; fast-path + // partitions, calls (fake) codegen, exits 0. var aiWinmd = Path.Combine(_tempDirectory.FullName, "fake-cache", "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); @@ -185,9 +182,7 @@ public async Task AddJsBindings_LockfileFastPath_UsedWhenHashMatches() var exitCode = await ParseAndInvokeWithCaptureAsync(addCmd, new[] { _tempDirectory.FullName, "--ai", "--force" }); Assert.AreEqual(0, exitCode); - // Lockfile path produces the AI winmd directly from the lockfile — - // no NuGet cache glob. If we'd taken fallback we'd fail since the - // (real) cache dir doesn't have proper layout. + // Lockfile path supplies the winmd directly — no NuGet cache glob. Assert.AreEqual(1, _fakeCodegen.Calls.Count); Assert.AreEqual(1, _fakeCodegen.Calls[0].EmitWinmds.Length); } @@ -195,9 +190,8 @@ public async Task AddJsBindings_LockfileFastPath_UsedWhenHashMatches() [TestMethod] public async Task AddJsBindings_StaleYamlHash_FallsBackOrFailsCleanly() { - // Stale-hash lockfile → fast-path rejects → fallback fails (no NuGet - // cache seeded). Either outcome is fine as long as we don't silently - // use the stale data. + // Stale-hash lockfile → fast-path rejects → fallback fails (no + // NuGet cache); never silently uses stale data. var aiWinmd = Path.Combine(_tempDirectory.FullName, "fake-cache", "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); Directory.CreateDirectory(Path.GetDirectoryName(aiWinmd)!); @@ -244,7 +238,7 @@ public async Task AddJsBindings_StaleYamlHash_FallsBackOrFailsCleanly() public async Task AddJsBindings_LockfileMissingWinmdPaths_FallsBackOrFailsCleanly() { // Lockfile references winmd paths that don't exist on disk - // (simulates `nuget locals all -clear` between restore and add). + // (e.g. `nuget locals all -clear` between restore and add). var bogusAiWinmd = Path.Combine(_tempDirectory.FullName, "deleted-cache", "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); // Intentionally do NOT create the file. @@ -294,8 +288,7 @@ public async Task AddJsBindings_LockfileMissingWinmdPaths_FallsBackOrFailsCleanl [TestMethod] public async Task AddJsBindings_CodegenThrows_PropagatesAsExit1() { - // FailWith causes the fake codegen to throw — caller must surface - // exit 1 (not silently succeed). + // FailWith makes fake codegen throw; caller must surface exit 1. var aiWinmd = Path.Combine(_tempDirectory.FullName, "fake-cache", "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); SetUpWorkspaceWithLockfile( @@ -319,8 +312,8 @@ public async Task AddJsBindings_CodegenThrows_PropagatesAsExit1() [TestMethod] public async Task AddJsBindings_AllScopedPackagesCategorizedAsSkip_FailsBeforeCodegen() { - // Scope narrows to a single package that gets categorized as Skip - // → emit set is empty → must fail before spawning codegen. + // Scope narrows to a single Skip-categorized package → empty emit + // set → must fail before spawning codegen. var winuiWinmd = Path.Combine(_tempDirectory.FullName, "fake-cache", "microsoft.windowsappsdk.winui", "1.8.39", "metadata", "Microsoft.WindowsAppSDK.WinUI.winmd"); Directory.CreateDirectory(Path.GetDirectoryName(winuiWinmd)!); @@ -376,9 +369,8 @@ public async Task AddJsBindings_AllScopedPackagesCategorizedAsSkip_FailsBeforeCo [TestMethod] public async Task AddJsBindings_ForceChangesOutput_OldOutputCleanupOnlyAfterCodegenSuccess() { - // M7 contract: when --force --output changes the output path AND - // codegen succeeds, the previous managed dir is wiped (with marker - // gating); unmanaged dirs are preserved. + // M7: --force --output change wipes a managed old dir on success, + // preserves an unmanaged one (marker-gated). var aiWinmd = Path.Combine(_tempDirectory.FullName, "fake-cache", "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); SetUpWorkspaceWithLockfile( @@ -443,8 +435,7 @@ await File.WriteAllTextAsync(configPath, [TestMethod] public async Task AddJsBindings_CodegenFails_OldOutputIsPreserved() { - // M7 contract: codegen failure must leave the old bindings dir - // untouched (don't wipe before we know the new bindings will land). + // M7: codegen failure must leave the old bindings dir untouched. var aiWinmd = Path.Combine(_tempDirectory.FullName, "fake-cache", "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); SetUpWorkspaceWithLockfile( @@ -487,8 +478,7 @@ await File.WriteAllTextAsync(configPath, [TestMethod] public async Task AddJsBindings_AdditionalWinmds_FlowsIntoCodegenEmitSet() { - // jsBindings.additionalWinmds entries must be passed to codegen - // as user-additional emit winmds. + // additionalWinmds entries must reach codegen as user-additional emit. var aiWinmd = Path.Combine(_tempDirectory.FullName, "fake-cache", "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); SetUpWorkspaceWithLockfile( @@ -571,10 +561,8 @@ await File.WriteAllTextAsync(configPath, $"additionalRefs must surface to codegen via UserAdditionalRefs. Got: {string.Join(", ", call.UserAdditionalRefs)}"); } - // an attacker-controlled winapp.yaml with additionalWinmds - // or additionalRefs pointing at a UNC path must NOT be probed (which - // would trigger an SMB handshake and leak NTLM credentials). The - // entries are dropped silently-from-codegen but logged. + // UNC paths in additionalWinmds must be rejected without probing + // (FileInfo.Exists on a UNC triggers SMB / NTLM leak). [TestMethod] public async Task AddJsBindings_AdditionalWinmds_UncEntry_Rejected_NotProbedNotPassedToCodegen() { @@ -586,16 +574,13 @@ public async Task AddJsBindings_AdditionalWinmds_UncEntry_Rejected_NotProbedNotP ("Microsoft.WindowsAppSDK.AI", "1.8.39", "emit", new[] { aiWinmd }), }); - // Yaml contains BOTH a benign local entry AND a UNC entry. Only - // the benign one should reach codegen. + // Yaml has a benign local entry + a UNC entry; only the benign one + // should reach codegen. var legitWinmd = Path.Combine(_tempDirectory.FullName, "vendor", "Legit.winmd"); Directory.CreateDirectory(Path.GetDirectoryName(legitWinmd)!); File.WriteAllText(legitWinmd, "stub"); - // \\nonexistent-attacker.invalid\share\evil.winmd - // Using `.invalid` per RFC 2606 so even an accidental probe can't - // reach a real host. If our guard fails, FileInfo.Exists would - // still SMB-negotiate and the test would timeout / hang. + // RFC 2606 `.invalid` TLD — never resolves even if our guard fails. var uncWinmd = @"\\nonexistent-attacker.invalid\share\evil.winmd"; File.WriteAllText(Path.Combine(_tempDirectory.FullName, "winapp.yaml"), @@ -617,9 +602,7 @@ public async Task AddJsBindings_AdditionalWinmds_UncEntry_Rejected_NotProbedNotP var addCmd = GetRequiredService(); - // Bound the test runtime: if our guard fails, FileInfo.Exists on - // the UNC path can take 20+ seconds to time out via SMB - // negotiation. We want < 5s. + // Cap runtime: a failed guard means a 20s+ SMB timeout per UNC entry. var sw = System.Diagnostics.Stopwatch.StartNew(); var exit = await ParseAndInvokeWithCaptureAsync(addCmd, new[] { _tempDirectory.FullName, "--force" }); sw.Stop(); @@ -639,13 +622,12 @@ public async Task AddJsBindings_AdditionalWinmds_UncEntry_Rejected_NotProbedNotP $"UNC entry MUST be dropped — codegen received: {string.Join(", ", call.UserAdditionalWinmds)}"); } - // extraTypes-only cherry-pick: only additionalRefs + extraTypes, no - // bulk emit. Must succeed and forward refs + extraTypes to codegen. + // extraTypes-only: additionalRefs + extraTypes, no bulk emit. [TestMethod] public async Task AddJsBindings_ExtraTypesOnlyWithAdditionalRefs_Succeeds() { - // Workspace has WinAppSDK installed; jsBindings declares only - // additionalRefs + extraTypes (no packages, no additionalWinmds). + // jsBindings declares only additionalRefs + extraTypes (no packages, + // no additionalWinmds). var vendorWinmd = Path.Combine(_tempDirectory.FullName, "vendor", "Vendor.SDK.winmd"); Directory.CreateDirectory(Path.GetDirectoryName(vendorWinmd)!); File.WriteAllText(vendorWinmd, "stub"); @@ -712,10 +694,8 @@ public async Task AddJsBindings_ExtraTypesOnlyWithAdditionalRefs_Succeeds() call.Config.ExtraTypes[0].Classes.ToList()); } - // extraTypes-only flow with refs + only-malformed - // extraTypes (blank namespace OR empty classes list) must fail - // BEFORE codegen — otherwise we'd return success with zero bindings - // produced (DynWinrtCodegenService.RunAsync skips malformed entries). + // Only-malformed extraTypes (blank ns / empty classes) must fail + // before codegen — otherwise we'd return success with zero bindings. [TestMethod] public async Task AddJsBindings_ExtraTypesOnlyWithMalformedEntries_FailsBeforeCodegen() { @@ -744,8 +724,8 @@ public async Task AddJsBindings_ExtraTypesOnlyWithMalformedEntries_FailsBeforeCo lockfile, WinmdsLockfileJsonContext.Default.WinmdsLockfile); File.WriteAllText(Path.Combine(winappDir.FullName, "winmds.lock.json"), json); - // Two malformed entries — one blank namespace, one empty classes - // list. Codegen would silently skip both → zero output. + // Two malformed entries (blank ns + empty classes) → codegen would + // silently skip both. File.WriteAllText(Path.Combine(_tempDirectory.FullName, "winapp.yaml"), "packages:\n" + " - name: Microsoft.WindowsAppSDK\n" @@ -773,9 +753,264 @@ public async Task AddJsBindings_ExtraTypesOnlyWithMalformedEntries_FailsBeforeCo "Malformed-only extraTypes must fail rather than silently produce zero bindings."); Assert.AreEqual(0, _fakeCodegen.Calls.Count, "Codegen MUST NOT be invoked when all extraTypes would be skipped."); + } + + // Companion to the additionalWinmds UNC test: refs flow through the + // same lockfile-bypass route, so UNC entries must also be dropped. + [TestMethod] + public async Task AddJsBindings_AdditionalRefs_UncEntry_Rejected_NotProbedNotPassedToCodegen() + { + var aiWinmd = Path.Combine(_tempDirectory.FullName, "fake-cache", + "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); + SetUpWorkspaceWithLockfile( + lockfilePackages: new[] + { + ("Microsoft.WindowsAppSDK.AI", "1.8.39", "emit", new[] { aiWinmd }), + }); + + // Yaml has a benign local ref + a UNC ref; only the benign reaches codegen. + var legitRef = Path.Combine(_tempDirectory.FullName, "vendor", "Legit.Ref.winmd"); + Directory.CreateDirectory(Path.GetDirectoryName(legitRef)!); + File.WriteAllText(legitRef, "stub"); + + // RFC 2606 reserved TLD — never resolves, even if our guard fails. + var uncRef = @"\\nonexistent-attacker.invalid\share\evil.ref.winmd"; + + File.WriteAllText(Path.Combine(_tempDirectory.FullName, "winapp.yaml"), + "packages:\n" + + " - name: Microsoft.WindowsAppSDK\n" + + " version: 1.8.39\n" + + "jsBindings:\n" + + " output: bindings/winrt\n" + + " lang: js\n" + + " packages:\n" + + " - Microsoft.WindowsAppSDK.AI\n" + + " additionalRefs:\n" + + " - vendor/Legit.Ref.winmd\n" + + $" - {uncRef.Replace("\\", "\\\\")}\n"); + + File.WriteAllText( + Path.Combine(_tempDirectory.FullName, "package.json"), + """{"name":"app","version":"1.0.0","dependencies":{}}"""); + + var addCmd = GetRequiredService(); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + var exit = await ParseAndInvokeWithCaptureAsync(addCmd, new[] { _tempDirectory.FullName, "--force" }); + sw.Stop(); + + Assert.AreEqual(0, exit, $"Expected success; stderr: {ConsoleStdErr}"); + Assert.IsTrue(sw.ElapsedMilliseconds < 10_000, + $"UNC ref must be rejected without SMB probe (took {sw.ElapsedMilliseconds}ms; " + + "anything >5s suggests we did probe)."); + + Assert.AreEqual(1, _fakeCodegen.Calls.Count); + var call = _fakeCodegen.Calls[0]; Assert.IsTrue( - ConsoleStdOut.ToString().Contains("malformed", StringComparison.OrdinalIgnoreCase) - || ConsoleStdErr.ToString().Contains("malformed", StringComparison.OrdinalIgnoreCase), - $"Error message must call out the malformed extraTypes. stdout={ConsoleStdOut}; stderr={ConsoleStdErr}"); + call.UserAdditionalRefs.Any(p => p.EndsWith("Legit.Ref.winmd", StringComparison.OrdinalIgnoreCase)), + "Legit local ref must still reach codegen."); + Assert.IsFalse( + call.UserAdditionalRefs.Any(p => p.Contains("nonexistent-attacker.invalid", StringComparison.OrdinalIgnoreCase)), + $"UNC ref MUST be dropped — codegen received: {string.Join(", ", call.UserAdditionalRefs)}"); + } + + // M1 (round-6): absolute paths outside the workspace must be accepted. + // docs/js-bindings.md:85,216,400 advertise absolute-path support; pre-r6 + // the reparse-point guard used workspaceDir as boundary and silently + // dropped any out-of-workspace absolute path. + [TestMethod] + public async Task AddJsBindings_AdditionalWinmds_AbsolutePathOutsideWorkspace_ReachesCodegen() + { + var aiWinmd = Path.Combine(_tempDirectory.FullName, "fake-cache", + "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); + SetUpWorkspaceWithLockfile( + lockfilePackages: new[] + { + ("Microsoft.WindowsAppSDK.AI", "1.8.39", "emit", new[] { aiWinmd }), + }); + + // Stage a vendor winmd in a SIBLING directory (outside workspace). + var siblingDir = new DirectoryInfo(Path.Combine( + Path.GetTempPath(), + string.Concat("winapp-r6-abs-".AsSpan(), Guid.NewGuid().ToString("N").AsSpan(0, 8)))); + siblingDir.Create(); + var externalWinmd = Path.Combine(siblingDir.FullName, "External.winmd"); + File.WriteAllText(externalWinmd, "stub"); + + try + { + File.WriteAllText(Path.Combine(_tempDirectory.FullName, "winapp.yaml"), + "packages:\n" + + " - name: Microsoft.WindowsAppSDK\n" + + " version: 1.8.39\n" + + "jsBindings:\n" + + " output: bindings/winrt\n" + + " lang: js\n" + + " packages:\n" + + " - Microsoft.WindowsAppSDK.AI\n" + + " additionalWinmds:\n" + + $" - {externalWinmd.Replace("\\", "\\\\")}\n"); + + File.WriteAllText( + Path.Combine(_tempDirectory.FullName, "package.json"), + """{"name":"app","version":"1.0.0","dependencies":{}}"""); + + var addCmd = GetRequiredService(); + var exit = await ParseAndInvokeWithCaptureAsync(addCmd, new[] { _tempDirectory.FullName, "--force" }); + + Assert.AreEqual(0, exit, $"Expected success; stderr: {ConsoleStdErr}"); + Assert.AreEqual(1, _fakeCodegen.Calls.Count); + var call = _fakeCodegen.Calls[0]; + Assert.IsTrue( + call.UserAdditionalWinmds.Any(p => p.EndsWith("External.winmd", StringComparison.OrdinalIgnoreCase)), + $"Absolute path outside workspace must reach codegen. Got: {string.Join(", ", call.UserAdditionalWinmds)}"); + } + finally + { + try { siblingDir.Delete(recursive: true); } catch { /* best-effort cleanup */ } + } + } + + // M1 (round-6) companion: absolute additionalRefs must also be accepted + // (same boundary fix; both fields flow through ResolveAdditionalWinmds). + [TestMethod] + public async Task AddJsBindings_AdditionalRefs_AbsolutePathOutsideWorkspace_ReachesCodegen() + { + var aiWinmd = Path.Combine(_tempDirectory.FullName, "fake-cache", + "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); + SetUpWorkspaceWithLockfile( + lockfilePackages: new[] + { + ("Microsoft.WindowsAppSDK.AI", "1.8.39", "emit", new[] { aiWinmd }), + }); + + var siblingDir = new DirectoryInfo(Path.Combine( + Path.GetTempPath(), + string.Concat("winapp-r6-absref-".AsSpan(), Guid.NewGuid().ToString("N").AsSpan(0, 8)))); + siblingDir.Create(); + var externalRef = Path.Combine(siblingDir.FullName, "External.Ref.winmd"); + File.WriteAllText(externalRef, "stub"); + + try + { + File.WriteAllText(Path.Combine(_tempDirectory.FullName, "winapp.yaml"), + "packages:\n" + + " - name: Microsoft.WindowsAppSDK\n" + + " version: 1.8.39\n" + + "jsBindings:\n" + + " output: bindings/winrt\n" + + " lang: js\n" + + " packages:\n" + + " - Microsoft.WindowsAppSDK.AI\n" + + " additionalRefs:\n" + + $" - {externalRef.Replace("\\", "\\\\")}\n"); + + File.WriteAllText( + Path.Combine(_tempDirectory.FullName, "package.json"), + """{"name":"app","version":"1.0.0","dependencies":{}}"""); + + var addCmd = GetRequiredService(); + var exit = await ParseAndInvokeWithCaptureAsync(addCmd, new[] { _tempDirectory.FullName, "--force" }); + + Assert.AreEqual(0, exit, $"Expected success; stderr: {ConsoleStdErr}"); + Assert.AreEqual(1, _fakeCodegen.Calls.Count); + var call = _fakeCodegen.Calls[0]; + Assert.IsTrue( + call.UserAdditionalRefs.Any(p => p.EndsWith("External.Ref.winmd", StringComparison.OrdinalIgnoreCase)), + $"Absolute ref outside workspace must reach codegen. Got: {string.Join(", ", call.UserAdditionalRefs)}"); + } + finally + { + try { siblingDir.Delete(recursive: true); } catch { /* best-effort cleanup */ } + } + } + + // M8: when old/new output dirs nest (either direction), cleanup must + // be skipped or wiping old would erase the freshly generated bindings. + [TestMethod] + public async Task AddJsBindings_OutputChange_NewNestedInsideOld_CleanupSkipped() + { + // old = "bindings", new = "bindings/winrt" (child of old). + var aiWinmd = Path.Combine(_tempDirectory.FullName, "fake-cache", + "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); + SetUpWorkspaceWithLockfile( + lockfilePackages: new[] + { + ("Microsoft.WindowsAppSDK.AI", "1.8.39", "emit", new[] { aiWinmd }), + }); + + // Marker-gated old dir would normally be wiped — overlap guard skips it. + var oldDir = Path.Combine(_tempDirectory.FullName, "bindings"); + Directory.CreateDirectory(oldDir); + File.WriteAllText(Path.Combine(oldDir, "stale.js"), "// old"); + File.WriteAllText(Path.Combine(oldDir, DynWinrtCodegenService.ManagedMarkerFileName), "# managed"); + + var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); + await File.WriteAllTextAsync(configPath, + "packages:\n" + + " - name: Microsoft.WindowsAppSDK\n" + + " version: 1.8.39\n" + + "jsBindings:\n" + + " output: bindings\n" + + " lang: js\n" + + " packages:\n" + + " - Microsoft.WindowsAppSDK.AI\n"); + + File.WriteAllText( + Path.Combine(_tempDirectory.FullName, "package.json"), + """{"name":"app","version":"1.0.0","dependencies":{}}"""); + + var addCmd = GetRequiredService(); + var exit = await ParseAndInvokeWithCaptureAsync(addCmd, + new[] { _tempDirectory.FullName, "--force", "--output", "bindings/winrt" }); + + Assert.AreEqual(0, exit, $"Expected success; stderr: {ConsoleStdErr}"); + + var newFile = Path.Combine(_tempDirectory.FullName, "bindings", "winrt", "index.js"); + Assert.IsTrue(File.Exists(newFile), + "Freshly generated bindings MUST survive — overlap cleanup must not delete them."); + } + + [TestMethod] + public async Task AddJsBindings_OutputChange_OldNestedInsideNew_CleanupSkipped() + { + // old = "bindings/winrt" (child), new = "bindings" (parent). + var aiWinmd = Path.Combine(_tempDirectory.FullName, "fake-cache", + "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); + SetUpWorkspaceWithLockfile( + lockfilePackages: new[] + { + ("Microsoft.WindowsAppSDK.AI", "1.8.39", "emit", new[] { aiWinmd }), + }); + + var oldDir = Path.Combine(_tempDirectory.FullName, "bindings", "winrt"); + Directory.CreateDirectory(oldDir); + File.WriteAllText(Path.Combine(oldDir, "stale.js"), "// old"); + File.WriteAllText(Path.Combine(oldDir, DynWinrtCodegenService.ManagedMarkerFileName), "# managed"); + + var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); + await File.WriteAllTextAsync(configPath, + "packages:\n" + + " - name: Microsoft.WindowsAppSDK\n" + + " version: 1.8.39\n" + + "jsBindings:\n" + + " output: bindings/winrt\n" + + " lang: js\n" + + " packages:\n" + + " - Microsoft.WindowsAppSDK.AI\n"); + + File.WriteAllText( + Path.Combine(_tempDirectory.FullName, "package.json"), + """{"name":"app","version":"1.0.0","dependencies":{}}"""); + + var addCmd = GetRequiredService(); + var exit = await ParseAndInvokeWithCaptureAsync(addCmd, + new[] { _tempDirectory.FullName, "--force", "--output", "bindings" }); + + Assert.AreEqual(0, exit, $"Expected success; stderr: {ConsoleStdErr}"); + + var newFile = Path.Combine(_tempDirectory.FullName, "bindings", "index.js"); + Assert.IsTrue(File.Exists(newFile), + "Freshly generated bindings MUST survive at the new (parent) location."); } } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/ConfigServiceJsBindingsTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/ConfigServiceJsBindingsTests.cs index b601e7ff..97a55b67 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/ConfigServiceJsBindingsTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/ConfigServiceJsBindingsTests.cs @@ -706,4 +706,106 @@ public void SaveJsBindingsOnly_NewFile_WritesViaStringify() StringAssert.Contains(content, "jsBindings:"); StringAssert.Contains(content, "output: bindings/winrt"); } + + [TestMethod] + public void Load_ConfigPathIsSymlink_Throws() + { + // Plant a real winapp.yaml elsewhere, point ConfigPath at a + // symlink to it inside the workspace, and assert Load() refuses. + // Without this guard, a malicious workspace could redirect the + // editor (Save / SaveJsBindingsOnly) at any victim file the user + // has write access to. + var realDir = new DirectoryInfo( + Path.Combine(Path.GetTempPath(), $"ConfigSvcRealCfg_{Guid.NewGuid():N}")); + realDir.Create(); + try + { + var realCfg = Path.Combine(realDir.FullName, "real-winapp.yaml"); + File.WriteAllText(realCfg, "packages: []\n"); + + var linked = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); + try + { + File.CreateSymbolicLink(linked, realCfg); + } + catch (Exception ex) when (ex is UnauthorizedAccessException or IOException) + { + Assert.Inconclusive($"Could not create symlink: {ex.Message}"); + return; + } + + _configService.ConfigPath = new FileInfo(linked); + var ex2 = Assert.ThrowsExactly(() => _configService.Load()); + StringAssert.Contains(ex2.Message, "symbolic link", + "GuardConfigPath must explain why it refused the path"); + Assert.AreEqual("packages: []\n", File.ReadAllText(realCfg), + "Real config file must be untouched by the refused Load"); + } + finally + { + try { realDir.Delete(true); } catch { /* ignore */ } + } + } + + [TestMethod] + public void Save_ConfigDirAncestorIsJunction_Throws() + { + // Same threat at a directory ancestor: the parent of winapp.yaml + // is a junction. SaveJsBindingsOnly must refuse so user changes + // can't get redirected to the junction target. + var realDir = new DirectoryInfo( + Path.Combine(Path.GetTempPath(), $"ConfigSvcRealDir_{Guid.NewGuid():N}")); + realDir.Create(); + try + { + var junctionPath = Path.Combine(_tempDirectory.FullName, "nested"); + if (!TryCreateJunction(junctionPath, realDir.FullName)) + { + Assert.Inconclusive("Could not create a junction (CI may lack the privilege)."); + return; + } + + _configService.ConfigPath = new FileInfo( + Path.Combine(junctionPath, "winapp.yaml")); + File.WriteAllText(_configService.ConfigPath.FullName, "packages: []\n"); + + var ex2 = Assert.ThrowsExactly(() => + _configService.SaveJsBindingsOnly(new WinappConfig + { + JsBindings = new JsBindingsConfig { Lang = "js", Output = "bindings/winrt" }, + })); + StringAssert.Contains(ex2.Message, "symbolic link"); + } + finally + { + try { realDir.Delete(true); } catch { /* ignore */ } + } + } + + private static bool TryCreateJunction(string link, string target) + { + try + { + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = "cmd.exe", + Arguments = $"/c mklink /J \"{link}\" \"{target}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + using var p = System.Diagnostics.Process.Start(psi); + if (p is null) + { + return false; + } + p.WaitForExit(); + return p.ExitCode == 0 && Directory.Exists(link); + } + catch + { + return false; + } + } } \ No newline at end of file diff --git a/src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenArgvTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenArgvTests.cs index 99d123c9..e155bb7f 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenArgvTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenArgvTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation and Contributors. All rights reserved. // Licensed under the MIT License. +using WinApp.Cli.Helpers; using WinApp.Cli.Models; using WinApp.Cli.Services; @@ -177,7 +178,7 @@ public void MergeRefWinmds_DedupesByFullName_CaseInsensitive() } // ------------------------------------------------------------------------- - // IsNetworkPath — classify UNC / network paths so the + // PathSafety.IsNetworkPath — classify UNC / network paths so the // additionalWinmds / additionalRefs / lockfile path probes can refuse // to negotiate SMB with attacker-controlled hosts. // ------------------------------------------------------------------------- @@ -197,7 +198,7 @@ public void MergeRefWinmds_DedupesByFullName_CaseInsensitive() [DataRow("", false, "Empty")] public void IsNetworkPath_ClassifiesPathsCorrectly(string path, bool expected, string label) { - Assert.AreEqual(expected, JsBindingsWorkspaceService.IsNetworkPath(path), + Assert.AreEqual(expected, PathSafety.IsNetworkPath(path), $"Path classification mismatch for {label}: {path}"); } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenInvocationTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenInvocationTests.cs index ee659ba1..6b13f585 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenInvocationTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenInvocationTests.cs @@ -224,10 +224,23 @@ public void ResolveExecutableOnPath_NativeOnly_BareNameWithCmdExtension_Rejected } // ------------------------------------------------------------------------- - // ResolveCodegenInvocation — direct .exe wins; cli.js fallback; - // friendly error when both missing. + // ResolveCodegenInvocation — wrapper-bundled is the only trusted source. // ------------------------------------------------------------------------- + // Helper for arranging a wrapper layout under _temp. + private static string Arch => RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "arm64" : "x64"; + + private static FileInfo PlantCodegenExe(DirectoryInfo root) + { + var packageDir = new DirectoryInfo(Path.Combine( + root.FullName, "node_modules", "@microsoft", "dynwinrt-codegen")); + var binDir = new DirectoryInfo(Path.Combine(packageDir.FullName, "bin", Arch)); + binDir.Create(); + var exe = new FileInfo(Path.Combine(binDir.FullName, "dynwinrt-codegen.exe")); + File.WriteAllText(exe.FullName, ""); + return exe; + } + [TestMethod] public void ResolveCodegenInvocation_DirectExePreferred() { @@ -239,7 +252,7 @@ public void ResolveCodegenInvocation_DirectExePreferred() File.WriteAllBytes(exe.FullName, Array.Empty()); File.WriteAllText(Path.Combine(packageDir.FullName, "cli.js"), "// stub"); - var (resolved, args) = DynWinrtCodegenService.ResolveCodegenInvocation(_temp); + var (resolved, args) = DynWinrtCodegenService.ResolveCodegenInvocationCore(wrapperDir: _temp); Assert.AreEqual(exe.FullName, resolved, "Direct .exe must win over cli.js fallback"); Assert.AreEqual(0, args.Count, "Direct .exe call passes no prefix args"); @@ -253,23 +266,22 @@ public void ResolveCodegenInvocation_CliJsFallback_UsesQualifiedNodePath() var cli = new FileInfo(Path.Combine(packageDir.FullName, "cli.js")); File.WriteAllText(cli.FullName, "// stub"); - // The fallback now uses nativeOnly=true — only finds node.exe/.com. + // The fallback uses nativeOnly=true — only finds node.exe/.com. var resolvedNode = DynWinrtCodegenService.ResolveExecutableOnPath("node", nativeOnly: true); if (resolvedNode is null) { Assert.ThrowsExactly( - () => DynWinrtCodegenService.ResolveCodegenInvocation(_temp), + () => DynWinrtCodegenService.ResolveCodegenInvocationCore(wrapperDir: _temp), "Without a native node executable, the fallback must refuse."); return; } - var (exe, args) = DynWinrtCodegenService.ResolveCodegenInvocation(_temp); + var (exe, args) = DynWinrtCodegenService.ResolveCodegenInvocationCore(wrapperDir: _temp); Assert.AreEqual(resolvedNode, exe, "Node executable must be the fully-resolved PATH lookup."); Assert.IsTrue(Path.IsPathRooted(exe), "Spawned executable path must be absolute to prevent CWD-search hijacks."); - // error message in the fallback path mentions native node. var ext = Path.GetExtension(exe); Assert.IsTrue( ext.Equals(".exe", StringComparison.OrdinalIgnoreCase) @@ -282,66 +294,50 @@ public void ResolveCodegenInvocation_CliJsFallback_UsesQualifiedNodePath() [TestMethod] public void ResolveCodegenInvocation_NothingFound_ThrowsActionableError() { + // No wrapper install — error must point at the npm/yarn classic install. var ex = Assert.ThrowsExactly( - () => DynWinrtCodegenService.ResolveCodegenInvocation(_temp)); + () => DynWinrtCodegenService.ResolveCodegenInvocationCore( + wrapperDir: _temp.CreateSubdirectory("empty-wrapper"))); StringAssert.Contains(ex.Message, "@microsoft/dynwinrt-codegen"); StringAssert.Contains(ex.Message, "@microsoft/winappcli"); - StringAssert.Contains(ex.Message, "yarn berry"); - StringAssert.Contains(ex.Message, "pnpm"); + } + + [TestMethod] + public void ResolveCodegenInvocation_NullWrapperDir_ThrowsWithReinstallHint() + { + // wrapperDir is null on `dotnet run` and any host where Environment.ProcessPath + // is empty. The error must skip the per-dir search entirely and tell the user + // to reinstall the npm package rather than echoing .NET internals. + var ex = Assert.ThrowsExactly( + () => DynWinrtCodegenService.ResolveCodegenInvocationCore(wrapperDir: null)); + + StringAssert.Contains(ex.Message, "@microsoft/dynwinrt-codegen"); + StringAssert.Contains(ex.Message, "winapp install directory could not be determined"); + StringAssert.Contains(ex.Message, "reinstalling @microsoft/winappcli"); } [TestMethod] public void ResolveCodegenInvocation_UpwardLookup_FindsHoistedPackage() { + // Hoisted layout reachable by walking up from wrapperDir — npm/yarn-classic happy path. var repoRoot = _temp; - var nestedWorkspace = repoRoot.CreateSubdirectory("apps").CreateSubdirectory("electron-app"); + var nestedWrapper = repoRoot.CreateSubdirectory("apps").CreateSubdirectory("electron-app"); var packageDir = new DirectoryInfo(Path.Combine( repoRoot.FullName, "node_modules", "@microsoft", "dynwinrt-codegen")); packageDir.Create(); - var arch = RuntimeInformation.ProcessArchitecture switch - { - Architecture.Arm64 => "arm64", - _ => "x64", - }; - var exe = new FileInfo(Path.Combine(packageDir.FullName, "bin", arch, "dynwinrt-codegen.exe")); + var exe = new FileInfo(Path.Combine(packageDir.FullName, "bin", Arch, "dynwinrt-codegen.exe")); exe.Directory!.Create(); File.WriteAllText(exe.FullName, "stub"); - var (resolved, args) = DynWinrtCodegenService.ResolveCodegenInvocation(nestedWorkspace); + var (resolved, args) = DynWinrtCodegenService.ResolveCodegenInvocationCore(wrapperDir: nestedWrapper); Assert.AreEqual(exe.FullName, resolved, - "Resolver must walk upward from the nested workspace to find the codegen at the repo root."); + "Resolver must walk upward from the nested wrapper dir to find the hoisted codegen."); Assert.AreEqual(0, args.Count); } - [TestMethod] - public void ResolveCodegenInvocation_InnerNodeModulesShadowsOuter() - { - var repoRoot = _temp; - var nestedWorkspace = repoRoot.CreateSubdirectory("apps").CreateSubdirectory("inner"); - var arch = RuntimeInformation.ProcessArchitecture switch - { - Architecture.Arm64 => "arm64", - _ => "x64", - }; - - var outerExe = new FileInfo(Path.Combine( - repoRoot.FullName, "node_modules", "@microsoft", "dynwinrt-codegen", "bin", arch, "dynwinrt-codegen.exe")); - outerExe.Directory!.Create(); - File.WriteAllText(outerExe.FullName, "outer-stub"); - - var innerExe = new FileInfo(Path.Combine( - nestedWorkspace.FullName, "node_modules", "@microsoft", "dynwinrt-codegen", "bin", arch, "dynwinrt-codegen.exe")); - innerExe.Directory!.Create(); - File.WriteAllText(innerExe.FullName, "inner-stub"); - - var (resolved, _) = DynWinrtCodegenService.ResolveCodegenInvocation(nestedWorkspace); - Assert.AreEqual(innerExe.FullName, resolved, - "When package exists at multiple ancestors, the workspace-local one wins."); - } - // ------------------------------------------------------------------------- // SpawnCodegen — cancellation kills child process tree promptly. // ------------------------------------------------------------------------- @@ -401,4 +397,36 @@ public async Task SpawnCodegen_CancellationKillsLongRunningChild_WithoutHang() Assert.IsTrue(sw.ElapsedMilliseconds < 5_000, $"Cancel+kill should complete fast; took {sw.ElapsedMilliseconds}ms."); } + + // ------------------------------------------------------------------------- + // M6 — workspace-local codegen install must NOT be trusted as a fallback. + // The resolver searches up from the wrapper dir ONLY; anything planted + // under the user workspace must NOT short-circuit the wrapper-bundled + // requirement (this is the post-r3 security model). + // ------------------------------------------------------------------------- + + [TestMethod] + public void ResolveCodegenInvocation_WorkspaceLocalInstall_NotTrustedWhenWrapperEmpty() + { + // Plant a fully-formed codegen exe under a SIBLING dir of the wrapper + // (think: user workspace at `_temp/workspace/...`, wrapper at + // `_temp/empty-wrapper/`). The resolver must refuse — workspace-local + // installs no longer count as a fallback. + var workspaceRoot = _temp.CreateSubdirectory("workspace"); + var workspaceCodegen = PlantCodegenExe(workspaceRoot); + Assert.IsTrue(workspaceCodegen.Exists, "fixture sanity"); + + var emptyWrapper = _temp.CreateSubdirectory("empty-wrapper"); + + // The wrapper dir (and its ancestor chain) does NOT contain the + // codegen install; the workspace install must NOT rescue this. + var ex = Assert.ThrowsExactly( + () => DynWinrtCodegenService.ResolveCodegenInvocationCore(wrapperDir: emptyWrapper)); + + StringAssert.Contains(ex.Message, "@microsoft/dynwinrt-codegen", + "Refusal message must explain what was missing."); + // The error must not point at the workspace plant. + Assert.IsFalse(ex.Message.Contains(workspaceCodegen.FullName), + "Resolver must not have considered the workspace-local install."); + } } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/GenerateJsBindingsCommandTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/GenerateJsBindingsCommandTests.cs index 96dd366c..48237ea5 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/GenerateJsBindingsCommandTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/GenerateJsBindingsCommandTests.cs @@ -120,6 +120,30 @@ public async Task Generate_WithExistingJsBindings_DoesNotMutateYaml() "generate is read-only on yaml — file must be byte-identical."); } + // M3 (round-6): `node jsbindings generate` is documented as read-only. + // That contract covers package.json too — re-adding @microsoft/dynwinrt + // on every regen would silently un-do a deliberate user removal. + [TestMethod] + public async Task Generate_WithExistingJsBindings_DoesNotMutatePackageJson() + { + await WriteYamlWithJsBindingsAsync("generated-js"); + + // package.json WITHOUT @microsoft/dynwinrt in dependencies — the + // user has deliberately removed it, and generate must respect that. + var packageJsonPath = Path.Combine(_tempDirectory.FullName, "package.json"); + const string originalPkg = """{"name":"app","version":"1.0.0","dependencies":{"react":"18.0.0"}}"""; + await File.WriteAllTextAsync(packageJsonPath, originalPkg); + + var cmd = GetRequiredService(); + var args = new[] { _tempDirectory.FullName }; + + await ParseAndInvokeWithCaptureAsync(cmd, args); + + var after = await File.ReadAllTextAsync(packageJsonPath); + Assert.AreEqual(originalPkg, after, + "generate must NOT inject @microsoft/dynwinrt into package.json — that's add's job."); + } + [TestMethod] public async Task Generate_RoutesViaWinAppRootCommand() { diff --git a/src/winapp-CLI/WinApp.Cli.Tests/PathSafetyTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/PathSafetyTests.cs new file mode 100644 index 00000000..1d4ec606 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/PathSafetyTests.cs @@ -0,0 +1,328 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.IO; +using WinApp.Cli.Helpers; + +namespace WinApp.Cli.Tests; + +// Direct tests for the shared PathSafety helper. Pre-r3 the helper was +// only covered indirectly (through ConfigService / UserPackageJsonService / +// WinmdDiscovery), which made it easy for the helper to drift: e.g. the +// pre-r3 implementation used FileInfo.Exists internally, which probes the +// filesystem before the reparse-point flag check — defeating the helper's +// stated purpose. These tests pin the API directly so future edits can't +// silently regress the contract. +[TestClass] +public class PathSafetyTests +{ + private DirectoryInfo _tempDir = null!; + + [TestInitialize] + public void Setup() + { + _tempDir = new DirectoryInfo( + Path.Combine(Path.GetTempPath(), $"PathSafetyTests_{Guid.NewGuid():N}")); + _tempDir.Create(); + } + + [TestCleanup] + public void Teardown() + { + try { _tempDir.Delete(true); } catch { /* ignore */ } + } + + // --------------------------------------------------------------------- + // Containment + // --------------------------------------------------------------------- + + [TestMethod] + public void HasReparsePointOnPath_PathEqualsBoundary_ReturnsFalse() + { + // The boundary itself is a valid target — callers pass e.g. the + // workspace dir as both path and boundary when checking the root. + bool unsafePath = PathSafety.HasReparsePointOnPath(_tempDir.FullName, _tempDir.FullName); + Assert.IsFalse(unsafePath, "boundary == path is allowed when neither is a reparse point"); + } + + [TestMethod] + public void HasReparsePointOnPath_PathUnderBoundary_NoReparsePoints_ReturnsFalse() + { + var nested = Path.Combine(_tempDir.FullName, "sub", "deeper", "file.txt"); + bool unsafePath = PathSafety.HasReparsePointOnPath(nested, _tempDir.FullName); + Assert.IsFalse(unsafePath, "deep child under a clean boundary must pass"); + } + + [TestMethod] + public void HasReparsePointOnPath_PathOutsideBoundary_ReturnsTrue() + { + // A sibling of the boundary is NOT contained — refuse. + var sibling = Path.Combine(_tempDir.Parent!.FullName, "other-workspace", "file.txt"); + bool unsafePath = PathSafety.HasReparsePointOnPath(sibling, _tempDir.FullName); + Assert.IsTrue(unsafePath, "sibling-of-boundary must be rejected (containment violation)"); + } + + [TestMethod] + public void HasReparsePointOnPath_PathTraversingParent_ReturnsTrue() + { + // `..` lets a path escape the boundary; GetFullPath should + // normalise and containment should then reject. + var escape = Path.Combine(_tempDir.FullName, "..", "elsewhere", "file.txt"); + bool unsafePath = PathSafety.HasReparsePointOnPath(escape, _tempDir.FullName); + Assert.IsTrue(unsafePath, "..-escaping path must be rejected after normalisation"); + } + + [TestMethod] + public void HasReparsePointOnPath_PrefixCollision_ReturnsTrue() + { + // `C:\foo-bar\file` starts with `C:\foo` as a string but is NOT + // contained — the separator-after-boundary requirement guards + // against this kind of substring confusion. + var boundary = Path.Combine(_tempDir.FullName, "foo"); + var nearby = Path.Combine(_tempDir.FullName, "foo-bar", "file.txt"); + bool unsafePath = PathSafety.HasReparsePointOnPath(nearby, boundary); + Assert.IsTrue(unsafePath, "substring-prefix collisions must NOT count as containment"); + } + + // --------------------------------------------------------------------- + // UNC rejection + // --------------------------------------------------------------------- + + [TestMethod] + public void HasReparsePointOnPath_UncBoundary_ReturnsTrue() + { + bool unsafePath = PathSafety.HasReparsePointOnPath( + @"\\server\share\file.txt", + @"\\server\share"); + Assert.IsTrue(unsafePath, "UNC boundary must be refused outright"); + } + + [TestMethod] + public void HasReparsePointOnPath_UncPath_ReturnsTrue() + { + bool unsafePath = PathSafety.HasReparsePointOnPath( + @"\\server\share\file.txt", + _tempDir.FullName); + Assert.IsTrue(unsafePath, "UNC path must be refused outright"); + } + + [TestMethod] + public void HasReparsePointOnPath_LongPathPrefixLocal_TreatedAsLocal() + { + // \\?\C:\… is the long-path prefix for a local path; NOT UNC. + // It must NOT be rejected via the UNC check. + var longPath = @"\\?\" + Path.Combine(_tempDir.FullName, "file.txt"); + var longBoundary = @"\\?\" + _tempDir.FullName; + bool unsafePath = PathSafety.HasReparsePointOnPath(longPath, longBoundary); + Assert.IsFalse(unsafePath, + @"\\?\ long-path prefix on a local drive must NOT be treated as UNC"); + } + + [TestMethod] + public void HasReparsePointOnPath_LongPathUncPrefix_ReturnsTrue() + { + // \\?\UNC\server\share IS UNC, just behind the long-path prefix — + // must still be refused. + bool unsafePath = PathSafety.HasReparsePointOnPath( + @"\\?\UNC\server\share\file.txt", + @"\\?\UNC\server\share"); + Assert.IsTrue(unsafePath, @"\\?\UNC\ is still UNC and must be refused"); + } + + // --------------------------------------------------------------------- + // Missing segments + // --------------------------------------------------------------------- + + [TestMethod] + public void HasReparsePointOnPath_MissingLeaf_IsAllowed() + { + // Callers (e.g. ConfigService.SaveJsBindingsOnly) check the path + // BEFORE creating the file — a missing leaf must not be rejected. + var leaf = Path.Combine(_tempDir.FullName, "not-yet-written.yaml"); + Assert.IsFalse(File.Exists(leaf)); + bool unsafePath = PathSafety.HasReparsePointOnPath(leaf, _tempDir.FullName); + Assert.IsFalse(unsafePath, "missing leaf must not trigger refusal"); + } + + [TestMethod] + public void HasReparsePointOnPath_MissingIntermediate_IsAllowed() + { + var nested = Path.Combine(_tempDir.FullName, "new", "subdir", "file.txt"); + bool unsafePath = PathSafety.HasReparsePointOnPath(nested, _tempDir.FullName); + Assert.IsFalse(unsafePath, "missing intermediates (about to be created) must pass"); + } + + // --------------------------------------------------------------------- + // Reparse-point detection + // --------------------------------------------------------------------- + + [TestMethod] + public void HasReparsePointOnPath_JunctionBoundary_ReturnsTrue() + { + // If the boundary itself is a junction, every descendant probe + // would silently follow it. We must refuse without ever touching + // a descendant. + var junctionParent = Path.Combine(_tempDir.FullName, "real"); + Directory.CreateDirectory(junctionParent); + var junction = Path.Combine(_tempDir.FullName, "boundary-as-junction"); + if (!TryCreateJunction(junction, junctionParent)) + { + Assert.Inconclusive("Could not create a junction (CI may lack the privilege)."); + return; + } + + try + { + var leaf = Path.Combine(junction, "file.txt"); + bool unsafePath = PathSafety.HasReparsePointOnPath(leaf, junction); + Assert.IsTrue(unsafePath, "boundary being a junction must be refused"); + } + finally + { + try { Directory.Delete(junction, recursive: false); } catch { /* ignore */ } + } + } + + [TestMethod] + public void HasReparsePointOnPath_JunctionIntermediate_ReturnsTrue() + { + // A junction on an ancestor between boundary and the leaf must + // also be refused. + var real = Path.Combine(_tempDir.FullName, "real-target"); + Directory.CreateDirectory(real); + var junctionDir = Path.Combine(_tempDir.FullName, "linked"); + if (!TryCreateJunction(junctionDir, real)) + { + Assert.Inconclusive("Could not create a junction (CI may lack the privilege)."); + return; + } + + try + { + var leaf = Path.Combine(junctionDir, "file.txt"); + bool unsafePath = PathSafety.HasReparsePointOnPath(leaf, _tempDir.FullName); + Assert.IsTrue(unsafePath, "junction on the descent path must be refused"); + } + finally + { + try { Directory.Delete(junctionDir, recursive: false); } catch { /* ignore */ } + } + } + + // Spawns `cmd /c mklink /J` to create a junction (the only reparse- + // point creation that does NOT require Developer Mode / admin on a + // typical CI box). Returns false on any failure so callers can mark + // the test inconclusive instead of failing. + private static bool TryCreateJunction(string link, string target) + { + try + { + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = "cmd.exe", + Arguments = $"/c mklink /J \"{link}\" \"{target}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + using var p = System.Diagnostics.Process.Start(psi); + if (p is null) + { + return false; + } + p.WaitForExit(5000); + return p.ExitCode == 0 && Directory.Exists(link); + } + catch + { + return false; + } + } + + // --------------------------------------------------------------------- + // Drive-root boundary regression (round-4 M1) + // --------------------------------------------------------------------- + + [TestMethod] + public void HasReparsePointOnPath_DriveRootBoundary_StillRejectsJunctionDescendant() + { + // If the boundary is a bare drive root (`C:\`) the descent loop + // must still call Path.Combine with a rooted prefix — otherwise + // Path.Combine("C:", "foo") yields a drive-relative "C:foo" + // (resolved against the per-drive CWD), the wrong inode is + // probed, and the reparse-point flag is missed. This test pins + // that the drive-root path is normalized to keep the separator. + var junctionDir = Path.Combine(_tempDir.FullName, "boundary-drive-root-junction"); + if (!TryCreateJunction(junctionDir, Path.GetTempPath())) + { + Assert.Inconclusive("Could not create a junction (CI may lack the privilege)."); + return; + } + + try + { + // Boundary = drive root (e.g. `C:\`). Path is a junction + // descendant. Pre-r4 this silently returned false (allowed). + var drive = Path.GetPathRoot(_tempDir.FullName)!; + var leaf = Path.Combine(junctionDir, "victim.yaml"); + bool unsafePath = PathSafety.HasReparsePointOnPath(leaf, drive); + Assert.IsTrue(unsafePath, "drive-root boundary must still detect junction on descent"); + } + finally + { + try { Directory.Delete(junctionDir, recursive: false); } catch { /* ignore */ } + } + } + + // --------------------------------------------------------------------- + // AtomicWriteAllText (round-4 M3) + // --------------------------------------------------------------------- + + [TestMethod] + public void AtomicWriteAllText_NewFile_WritesContentsAndLeavesNoTempBehind() + { + var target = Path.Combine(_tempDir.FullName, "out.yaml"); + const string contents = "key: value\n"; + + PathSafety.AtomicWriteAllText(target, contents, System.Text.Encoding.UTF8); + + Assert.IsTrue(File.Exists(target), "target file must exist after atomic write"); + Assert.AreEqual(contents, File.ReadAllText(target)); + // No stray sibling temp files left behind on success. + var siblings = Directory.GetFiles(_tempDir.FullName, "out.yaml.tmp-*"); + Assert.AreEqual(0, siblings.Length, "atomic write must remove its sibling .tmp on success"); + } + + [TestMethod] + public void AtomicWriteAllText_ExistingFile_OverwritesContents() + { + var target = Path.Combine(_tempDir.FullName, "existing.yaml"); + File.WriteAllText(target, "old contents"); + + PathSafety.AtomicWriteAllText(target, "new contents", System.Text.Encoding.UTF8); + + Assert.AreEqual("new contents", File.ReadAllText(target)); + } + + [TestMethod] + public void AtomicWriteAllText_DestinationDirMissing_ThrowsAndCleansSiblingTemp() + { + // Stage failure: the sibling temp creation calls FileStream with + // FileMode.CreateNew under a non-existent parent dir, throwing + // DirectoryNotFoundException. The cleanup branch must still run + // so we don't leak the .tmp sibling (or in this case, prove that + // nothing was ever written to the missing dir's parent). + var missingDir = Path.Combine(_tempDir.FullName, "no-such-dir"); + var target = Path.Combine(missingDir, "out.yaml"); + + Assert.ThrowsExactly(() => + PathSafety.AtomicWriteAllText(target, "x", System.Text.Encoding.UTF8)); + + Assert.IsFalse(Directory.Exists(missingDir), "atomic write must not create parent dirs"); + // No sibling temp under the workspace either (the failure happened + // before any file existed). + var stray = Directory.GetFiles(_tempDir.FullName, "*.tmp-*", SearchOption.AllDirectories); + Assert.AreEqual(0, stray.Length, "no .tmp sibling should be left behind on failure"); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/TestDoubles/FakeJsBindingsWorkspaceService.cs b/src/winapp-CLI/WinApp.Cli.Tests/TestDoubles/FakeJsBindingsWorkspaceService.cs index 2ff9fce6..fded92c9 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/TestDoubles/FakeJsBindingsWorkspaceService.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/TestDoubles/FakeJsBindingsWorkspaceService.cs @@ -41,6 +41,14 @@ public void EnsureRuntimeDependencyAndPrintHint(DirectoryInfo workspaceDirectory public Task AddAsync(AddJsBindingsOptions options, CancellationToken cancellationToken = default) => Task.FromResult(0); + public List GenerateCalls { get; } = new(); + + // When set, GenerateAsync returns this exit code instead of 0. + public int GenerateResult { get; set; } + public Task GenerateAsync(GenerateJsBindingsOptions options, CancellationToken cancellationToken = default) - => Task.FromResult(0); + { + GenerateCalls.Add(options); + return Task.FromResult(GenerateResult); + } } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UserPackageJsonServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/UserPackageJsonServiceTests.cs index b670eaa0..7fb31fc2 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UserPackageJsonServiceTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UserPackageJsonServiceTests.cs @@ -191,4 +191,137 @@ public void EnsureRuntimeDependency_NullOrEmptyArgs_Throws() Assert.ThrowsExactly(() => _service.EnsureRuntimeDependency(_tempDir, "x", "")); } + + // ---- Reparse-point guard (M9) ---- + + [TestMethod] + public void EnsureRuntimeDependency_PackageJsonIsSymlink_Throws() + { + // Plant a real package.json elsewhere, then symlink it into the + // workspace. The guard must refuse to rewrite via the symlink so a + // malicious workspace can't redirect the edit to a victim file. + var realDir = new DirectoryInfo( + Path.Combine(Path.GetTempPath(), $"UserPkgJsonTests_Real_{Guid.NewGuid():N}")); + realDir.Create(); + try + { + var realPackageJson = Path.Combine(realDir.FullName, "package.json"); + File.WriteAllText(realPackageJson, "{\"name\":\"victim\"}"); + + try + { + File.CreateSymbolicLink(PackageJsonPath, realPackageJson); + } + catch (Exception ex) when (ex is UnauthorizedAccessException or IOException) + { + // Creating a symlink on Windows requires admin or Developer + // Mode. Skip this assertion silently rather than fail the + // suite on locked-down CI/dev machines. + Assert.Inconclusive($"Could not create a symbolic link in this environment: {ex.Message}"); + return; + } + + var ex2 = Assert.ThrowsExactly(() => + _service.EnsureRuntimeDependency(_tempDir, "@microsoft/dynwinrt", "1.0.0")); + StringAssert.Contains(ex2.Message, "symbolic link", "Error must explain the refusal"); + // Real file must be untouched. + Assert.AreEqual("{\"name\":\"victim\"}", File.ReadAllText(realPackageJson)); + } + finally + { + try { realDir.Delete(true); } catch { /* ignore */ } + } + } + + [TestMethod] + public void EnsureRuntimeDependency_AncestorIsJunction_Throws() + { + // Same threat as a file-level symlink, but at a directory ancestor: + // `\\nested\` is a junction pointing at a real dir + // that holds a package.json. Refusing must cover this case too. + var realDir = new DirectoryInfo( + Path.Combine(Path.GetTempPath(), $"UserPkgJsonTests_RealDir_{Guid.NewGuid():N}")); + realDir.Create(); + try + { + File.WriteAllText(Path.Combine(realDir.FullName, "package.json"), "{\"name\":\"victim\"}"); + + var junctionPath = Path.Combine(_tempDir.FullName, "nested"); + try + { + // mklink /J is non-elevating on Windows even without Dev Mode. + Directory.CreateSymbolicLink(junctionPath, realDir.FullName); + } + catch (Exception ex) when (ex is UnauthorizedAccessException or IOException) + { + Assert.Inconclusive($"Could not create a directory link in this environment: {ex.Message}"); + return; + } + + var junctionWorkspace = new DirectoryInfo(junctionPath); + var ex2 = Assert.ThrowsExactly(() => + _service.EnsureRuntimeDependency(junctionWorkspace, "@microsoft/dynwinrt", "1.0.0")); + StringAssert.Contains(ex2.Message, "symbolic link", "Error must explain the refusal"); + } + finally + { + try { realDir.Delete(true); } catch { /* ignore */ } + } + } + + [TestMethod] + public void EnsureRuntimeDependency_LockedPackageJson_ThrowsWrapped() + { + File.WriteAllText(PackageJsonPath, "{\"name\":\"my-app\",\"version\":\"1.0.0\"}"); + // Hold an exclusive lock so the service's atomic write + // (or its preceding read) fails. The wrapper must surface this as + // an InvalidOperationException, not a raw IOException — otherwise + // CLI orchestration aborts mid-init instead of degrading to a + // warning. + using var locker = new FileStream( + PackageJsonPath, FileMode.Open, FileAccess.Read, FileShare.None); + var ex = Assert.ThrowsExactly(() => + _service.EnsureRuntimeDependency(_tempDir, "@microsoft/dynwinrt", "1.0.0")); + Assert.IsNotNull(ex.InnerException); + Assert.IsTrue( + ex.InnerException is IOException or UnauthorizedAccessException, + $"Inner exception should be IOException or UnauthorizedAccessException, was {ex.InnerException?.GetType().Name}"); + } + + // --------------------------------------------------------------------- + // L2 — write-path catch reachable when destination can be READ but + // cannot be REPLACED. Pre-existing LockedPackageJson_ThrowsWrapped uses + // FileShare.None which lights up the READ catch path; this test holds + // the destination open for FileShare.Read (so the service's read + // succeeds) and asserts the WRITE catch wraps the rename failure with + // the actionable "Failed to write" prefix. + // --------------------------------------------------------------------- + + [TestMethod] + public void EnsureRuntimeDependency_DestinationWriteLocked_WrapsWithFailedToWrite() + { + File.WriteAllText(PackageJsonPath, "{\"name\":\"my-app\",\"version\":\"1.0.0\"}"); + + // Open WITH FileShare.Read: other readers (the service's + // File.OpenRead / File.ReadAllText) succeed, but File.Move + // overwriting the destination fails because no FileShare.Write + // is granted. That lands in the catch at lines 120-128 of + // UserPackageJsonService. + using var locker = new FileStream( + PackageJsonPath, + FileMode.Open, + FileAccess.Read, + FileShare.Read); + + var ex = Assert.ThrowsExactly(() => + _service.EnsureRuntimeDependency(_tempDir, "@microsoft/dynwinrt", "1.0.0")); + + StringAssert.Matches( + ex.Message, + new System.Text.RegularExpressions.Regex("(Failed|No permission) to write"), + "Wrapper must surface the I/O / permission failure with an actionable 'to write' prefix."); + Assert.IsTrue( + ex.InnerException is IOException or UnauthorizedAccessException, + $"Inner must be IOException / UnauthorizedAccessException, was {ex.InnerException?.GetType().Name}"); + } } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/WinappConfigDocumentTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/WinappConfigDocumentTests.cs new file mode 100644 index 00000000..f1c91405 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/WinappConfigDocumentTests.cs @@ -0,0 +1,497 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.Models; +using WinApp.Cli.Services; + +// CA1861 ("avoid constant arrays as arguments") is real perf advice in hot +// paths, but these tests use one-shot literal arrays as fixture data and +// extracting each to a `static readonly` field would make the round-trip +// cases noticeably harder to read. Suppress at file scope. +#pragma warning disable CA1861 + +namespace WinApp.Cli.Tests; + +// Direct round-trip tests for the WinappConfigDocument YAML grammar. +// Pre-r3 the document type was only exercised indirectly through +// ConfigService, which made it easy for the parser and the renderer to +// drift apart silently. These tests pin Render → Parse fidelity on the +// awkward inputs (single-quote escaping, `#` inside values, extraTypes +// key ordering) that the round-3 review surfaced as silent data loss. +[TestClass] +public class WinappConfigDocumentTests +{ + private static WinappConfig RoundTrip(WinappConfig cfg) + { + var yaml = new WinappConfigDocument(cfg).Render(); + return WinappConfigDocument.Parse(yaml).Config; + } + + // --------------------------------------------------------------------- + // M10 — single-quoted scalar escaping + // --------------------------------------------------------------------- + + [TestMethod] + public void RoundTrip_OutputContainingApostrophe_PreservesValue() + { + // YAML single-quoted scalars escape a literal `'` as `''`. The + // renderer used to emit the doubled form correctly, but the parser + // didn't un-double on read — every save grew the apostrophe count. + var cfg = new WinappConfig + { + JsBindings = new JsBindingsConfig + { + Lang = "js", + Output = "bindings/O'Brien", + }, + }; + + var rt = RoundTrip(cfg); + + Assert.IsNotNull(rt.JsBindings); + Assert.AreEqual("bindings/O'Brien", rt.JsBindings!.Output); + } + + [TestMethod] + public void RoundTrip_OutputContainingHashChar_PreservesValue() + { + // An unquoted `#` introduces a comment; the renderer must quote and + // the parser must NOT strip the `#` from the quoted value. + var cfg = new WinappConfig + { + JsBindings = new JsBindingsConfig + { + Lang = "js", + Output = "bindings/c#-output", + }, + }; + + var rt = RoundTrip(cfg); + + Assert.AreEqual("bindings/c#-output", rt.JsBindings!.Output); + } + + [TestMethod] + public void RoundTrip_PackageNameContainingApostrophe_PreservesValue() + { + // packages: list items go through the same QuoteScalar/SanitizeScalar + // pipeline; cover that path explicitly. + var cfg = new WinappConfig + { + JsBindings = new JsBindingsConfig + { + Lang = "js", + Output = "bindings/winrt", + Packages = { "Some.Vendor's.Package" }, + }, + }; + + var rt = RoundTrip(cfg); + + CollectionAssert.AreEqual( + new[] { "Some.Vendor's.Package" }, + rt.JsBindings!.Packages); + } + + // --------------------------------------------------------------------- + // M8 — extraTypes parser must be key-order-independent + // --------------------------------------------------------------------- + + [TestMethod] + public void Parse_ExtraTypesWithClassesBeforeNamespace_DoesNotDropEntry() + { + // A user (or a YAML formatter that alphabetises keys) may write + // `classes:` before `namespace:`. Pre-r3 the parser required the + // dash line to be `- namespace:` exactly and silently dropped + // entries that led with `- classes:`. + var yaml = string.Join('\n', new[] + { + "packages: []", + "jsBindings:", + " lang: js", + " output: bindings/winrt", + " extraTypes:", + " - classes:", + " - SomeClass", + " namespace: My.Namespace", + "", + }); + + var cfg = WinappConfigDocument.Parse(yaml).Config; + + Assert.IsNotNull(cfg.JsBindings); + Assert.AreEqual(1, cfg.JsBindings!.ExtraTypes.Count, "classes-first entry must NOT be dropped"); + var entry = cfg.JsBindings.ExtraTypes[0]; + Assert.AreEqual("My.Namespace", entry.Namespace); + CollectionAssert.AreEqual(new[] { "SomeClass" }, entry.Classes); + } + + [TestMethod] + public void Parse_ExtraTypesMultipleEntries_BothOrderingsCoexist() + { + var yaml = string.Join('\n', new[] + { + "packages: []", + "jsBindings:", + " lang: js", + " output: bindings/winrt", + " extraTypes:", + " - namespace: First.Ns", + " classes:", + " - First.A", + " - First.B", + " - classes:", + " - Second.X", + " namespace: Second.Ns", + "", + }); + + var cfg = WinappConfigDocument.Parse(yaml).Config; + + Assert.AreEqual(2, cfg.JsBindings!.ExtraTypes.Count); + Assert.AreEqual("First.Ns", cfg.JsBindings.ExtraTypes[0].Namespace); + CollectionAssert.AreEqual( + new[] { "First.A", "First.B" }, + cfg.JsBindings.ExtraTypes[0].Classes); + Assert.AreEqual("Second.Ns", cfg.JsBindings.ExtraTypes[1].Namespace); + CollectionAssert.AreEqual( + new[] { "Second.X" }, + cfg.JsBindings.ExtraTypes[1].Classes); + } + + [TestMethod] + public void RoundTrip_ExtraTypesWithApostropheInClassName_Preserved() + { + // Cover the QuoteScalar path for extraTypes entries too — both + // namespace and classes go through it. + var cfg = new WinappConfig + { + JsBindings = new JsBindingsConfig + { + Lang = "js", + Output = "bindings/winrt", + ExtraTypes = + { + new JsBindingsExtraType + { + Namespace = "Vendor's.Namespace", + Classes = { "Vendor's.Class" }, + }, + }, + }, + }; + + var rt = RoundTrip(cfg); + + Assert.AreEqual(1, rt.JsBindings!.ExtraTypes.Count); + Assert.AreEqual("Vendor's.Namespace", rt.JsBindings.ExtraTypes[0].Namespace); + CollectionAssert.AreEqual( + new[] { "Vendor's.Class" }, + rt.JsBindings.ExtraTypes[0].Classes); + } + + // --------------------------------------------------------------------- + // QuoteScalar coverage — values the renderer MUST quote + // --------------------------------------------------------------------- + + [TestMethod] + public void RoundTrip_WindowsPathWithDriveColon_PreservedAsString() + { + // `C:\foo` contains a `:` so the renderer must quote — otherwise + // the next load would re-parse it as a mapping. + var cfg = new WinappConfig + { + JsBindings = new JsBindingsConfig + { + Lang = "js", + Output = "bindings/winrt", + AdditionalWinmds = { @"C:\winmds\extra.winmd" }, + }, + }; + + var rt = RoundTrip(cfg); + + CollectionAssert.AreEqual( + new[] { @"C:\winmds\extra.winmd" }, + rt.JsBindings!.AdditionalWinmds); + } + + [TestMethod] + public void RoundTrip_ReservedYamlBooleanLikeValue_PreservedAsString() + { + // A package id like `no` (unlikely but legal) would be re-parsed + // as the YAML 1.1 boolean false; the renderer must quote. + var cfg = new WinappConfig + { + JsBindings = new JsBindingsConfig + { + Lang = "js", + Output = "bindings/winrt", + Packages = { "no" }, + }, + }; + + var rt = RoundTrip(cfg); + + CollectionAssert.AreEqual(new[] { "no" }, rt.JsBindings!.Packages); + } + + [TestMethod] + public void RoundTrip_ValueLeadingWithDash_PreservedAsString() + { + // A leading `-` would otherwise be parsed as a YAML list marker. + var cfg = new WinappConfig + { + JsBindings = new JsBindingsConfig + { + Lang = "js", + Output = "-leading-dash-dir/winrt", + }, + }; + + var rt = RoundTrip(cfg); + + Assert.AreEqual("-leading-dash-dir/winrt", rt.JsBindings!.Output); + } + + // --------------------------------------------------------------------- + // M7 — `packages:` must accept inline comments / trailing whitespace + // --------------------------------------------------------------------- + + [TestMethod] + public void Parse_PackagesHeaderWithInlineComment_StillCollectsEntries() + { + // Pre-r4 the parser required `t.Equals("packages:")` exactly, so + // a comment on the header line (`packages: # SDK pins`) silently + // reset the section to None and every subsequent `- name:` / + // `version:` line was dropped. `restore` then loaded zero + // packages and did nothing. Fixed by routing through IsTopLevelKey + // (the same comment-tolerant detector that `jsBindings:` uses). + var yaml = string.Join('\n', new[] + { + "packages: # SDK pins", + " - name: Microsoft.WindowsAppSDK", + " version: 1.8.39", + "", + }); + + var cfg = WinappConfigDocument.Parse(yaml).Config; + + Assert.AreEqual(1, cfg.Packages.Count, + "packages: with inline comment must still collect entries"); + Assert.AreEqual("Microsoft.WindowsAppSDK", cfg.Packages[0].Name); + Assert.AreEqual("1.8.39", cfg.Packages[0].Version); + } + + // r5-F1 regression — nested jsBindings sub-keys had the SAME exact-string + // equality bug as the top-level packages: header. An inline comment on + // any of additionalWinmds: / packages: / extraTypes: / classes: silently + // mis-routed the following list items into the previously-active list. + [TestMethod] + public void Parse_NestedAdditionalWinmdsHeaderWithInlineComment_DoesNotMisrouteEntries() + { + var yaml = string.Join('\n', new[] + { + "jsBindings:", + " lang: js", + " packages:", + " - Microsoft.WindowsAppSDK", + " additionalWinmds: # vendor SDKs go here", + " - vendor/Foo.winmd", + " additionalRefs: # ref-only WinMDs", + " - vendor/Bar.winmd", + "", + }); + + var cfg = WinappConfigDocument.Parse(yaml).Config; + + Assert.IsNotNull(cfg.JsBindings); + // Pre-fix: `vendor/Foo.winmd` would have stayed appended to + // js.Packages because additionalWinmds: with inline comment was + // missed; listMode stayed Packages. + CollectionAssert.AreEqual( + new[] { "Microsoft.WindowsAppSDK" }, + cfg.JsBindings!.Packages.ToList(), + "Packages must not absorb additionalWinmds entries after inline-comment header."); + CollectionAssert.AreEqual( + new[] { "vendor/Foo.winmd" }, + cfg.JsBindings.AdditionalWinmds.ToList(), + "additionalWinmds: header with inline comment must open the AdditionalWinmds list."); + CollectionAssert.AreEqual( + new[] { "vendor/Bar.winmd" }, + cfg.JsBindings.AdditionalRefs.ToList(), + "additionalRefs: header with inline comment must open the AdditionalRefs list."); + } + + [TestMethod] + public void Parse_NestedClassesHeaderWithInlineComment_OpensClassesListNotInlineScalar() + { + // For extraTypes[].classes:, the parser had two branches: + // 1. `t.Equals("classes:")` → open the classes list + // 2. `t.StartsWith("classes:")` → try to parse inline `[X,Y]` form + // With `classes: # comment` only branch 2 matched pre-fix; rest was + // `# comment` which is not `[…]`, so it silently fell through and + // the subsequent `- ClassName` lines were dropped. + var yaml = string.Join('\n', new[] + { + "jsBindings:", + " extraTypes:", + " - namespace: Windows.Foundation", + " classes: # only these types are emitted", + " - Uri", + " - PropertyValue", + "", + }); + + var cfg = WinappConfigDocument.Parse(yaml).Config; + + Assert.IsNotNull(cfg.JsBindings); + Assert.AreEqual(1, cfg.JsBindings!.ExtraTypes.Count); + CollectionAssert.AreEqual( + new[] { "Uri", "PropertyValue" }, + cfg.JsBindings.ExtraTypes[0].Classes.ToList(), + "classes: header with inline comment must open the classes list."); + } + + // --------------------------------------------------------------------- + // L1 — plain scalar with apostrophe + inline comment round-trip + // --------------------------------------------------------------------- + + [TestMethod] + public void Parse_PlainScalarApostropheWithInlineComment_StripsCommentNotApostrophe() + { + // A plain (unquoted) scalar like `output: foo's-dir # comment` + // must drop the ` # comment` suffix. Pre-r4 SanitizeScalar + // toggled inSingle on the apostrophe and then treated the `#` + // as "inside a single-quoted scalar", so the comment leaked into + // the value and a subsequent save re-quoted the whole thing. + var yaml = string.Join('\n', new[] + { + "jsBindings:", + " lang: js", + " output: foo's-dir # this is a comment, drop me", + "", + }); + + var cfg = WinappConfigDocument.Parse(yaml).Config; + + Assert.IsNotNull(cfg.JsBindings); + Assert.AreEqual("foo's-dir", cfg.JsBindings!.Output, + "plain-scalar apostrophe must NOT suppress inline-comment stripping"); + } + + // --------------------------------------------------------------------- + // M8 — SpliceJsBindingsInto contract (preserve comments / unknowns; + // honor null = remove; append when missing) + // --------------------------------------------------------------------- + + [TestMethod] + public void SpliceJsBindingsInto_PreservesLeadingCommentAndTrailingSections() + { + // The splice must rewrite only the jsBindings: block in place + // and leave every other byte of the existing yaml verbatim — + // including a leading comment line and the trailing packages: + // section. ConfigService.SaveJsBindingsOnly is the production + // caller; if this drifts, user-authored YAML loses comments + // every time `node jsbindings add/generate` runs. + var existing = string.Join('\n', new[] + { + "# user-managed file — do not edit jsBindings by hand", + "", + "jsBindings:", + " lang: ts", + " output: bindings/old", + "", + "packages:", + " - name: Microsoft.WindowsAppSDK", + " version: 1.8.39", + "", + }); + + var doc = new WinappConfigDocument(new WinappConfig + { + JsBindings = new JsBindingsConfig + { + Lang = "js", + Output = "bindings/new", + }, + }); + + var spliced = doc.SpliceJsBindingsInto(existing); + + // Leading comment + the entire packages section must survive. + StringAssert.Contains(spliced, "# user-managed file"); + StringAssert.Contains(spliced, "packages:"); + StringAssert.Contains(spliced, "Microsoft.WindowsAppSDK"); + StringAssert.Contains(spliced, "1.8.39"); + // The jsBindings block must reflect the new values. + StringAssert.Contains(spliced, "lang: js"); + StringAssert.Contains(spliced, "bindings/new"); + Assert.IsFalse(spliced.Contains("bindings/old"), + "old jsBindings.output must be gone after splice"); + } + + [TestMethod] + public void SpliceJsBindingsInto_NullJsBindings_RemovesBlockButKeepsRest() + { + // `JsBindings = null` means "remove the block". The rest of the + // file (other sections, comments) must remain untouched so a user + // can revert by hand-deleting their bindings declaration. + var existing = string.Join('\n', new[] + { + "packages:", + " - name: Microsoft.WindowsAppSDK", + " version: 1.8.39", + "", + "jsBindings:", + " lang: js", + " output: bindings/winrt", + "", + }); + + var doc = new WinappConfigDocument(new WinappConfig { JsBindings = null }); + + var spliced = doc.SpliceJsBindingsInto(existing); + + Assert.IsFalse(spliced.Contains("jsBindings:"), + "jsBindings: header must be gone after splice with null JsBindings"); + Assert.IsFalse(spliced.Contains("bindings/winrt"), + "old jsBindings body must be gone"); + StringAssert.Contains(spliced, "packages:"); + StringAssert.Contains(spliced, "Microsoft.WindowsAppSDK"); + } + + [TestMethod] + public void SpliceJsBindingsInto_NoExistingBlock_AppendsOneAndRoundTrips() + { + // When the user's yaml has no jsBindings: yet, splice must + // append one in a way that parses cleanly on the next load. + var existing = string.Join('\n', new[] + { + "packages:", + " - name: Microsoft.WindowsAppSDK", + " version: 1.8.39", + "", + }); + + var doc = new WinappConfigDocument(new WinappConfig + { + JsBindings = new JsBindingsConfig + { + Lang = "js", + Output = "bindings/winrt", + }, + }); + + var spliced = doc.SpliceJsBindingsInto(existing); + + // Existing section is preserved AND the new block parses back. + StringAssert.Contains(spliced, "packages:"); + StringAssert.Contains(spliced, "jsBindings:"); + var roundTripped = WinappConfigDocument.Parse(spliced).Config; + Assert.AreEqual(1, roundTripped.Packages.Count); + Assert.IsNotNull(roundTripped.JsBindings); + Assert.AreEqual("js", roundTripped.JsBindings!.Lang); + Assert.AreEqual("bindings/winrt", roundTripped.JsBindings.Output); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/WinmdsLockfileServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/WinmdsLockfileServiceTests.cs index f87e5bcc..656f695a 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/WinmdsLockfileServiceTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/WinmdsLockfileServiceTests.cs @@ -42,8 +42,7 @@ public void GetLockfilePath_LandsUnderWinappDir() public async Task WriteAsync_ProducesIndentedSchemaVersionedJson() { var winapp = _temp.CreateSubdirectory("winapp"); - // ExtractPackageIdFromPath requires the literal "packages" segment - // (the NuGet cache convention) — keep test fixtures aligned with that. + // ExtractPackageIdFromPath requires the literal "packages" segment. var cache = _temp.CreateSubdirectory("packages"); var winmd = new FileInfo(Path.Combine( cache.FullName, "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd")); @@ -165,9 +164,8 @@ public void BuildLockfile_VendorWinmdsOutsideCache_AreDropped() [TestMethod] public void BuildLockfile_PartitionFromLockfile_AppliesScopeAsEmitFilter_DemotesUnscopedToRefOnly() { - // scope narrows EMIT output, not codegen visibility. - // Unscoped packages (whose default category is Emit) must end up - // as RefOnly so cross-package type resolution still works. + // scope narrows EMIT, not codegen visibility — unscoped Emit + // packages must end up RefOnly for cross-package type resolution. var cache = _temp.CreateSubdirectory("packages"); var aiWinmd = MakeFile(cache, "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); var fdnWinmd = MakeFile(cache, "microsoft.windowsappsdk.foundation", "1.8.0", "metadata", "Microsoft.Windows.Foundation.winmd"); @@ -245,8 +243,8 @@ await _svc.WriteAsync( [TestMethod] public async Task TryReadAsync_Schema1Lockfile_ReturnsNull() { - // Existing pre-v2.3 lockfiles use schema=1. Readers must treat them - // as missing so the slow path (re-discovery) rebuilds the lockfile. + // pre-v2.3 lockfiles use schema=1; readers treat them as missing + // so the slow path rebuilds. var path = _svc.GetLockfilePath(_temp); await File.WriteAllTextAsync(path.FullName, "{\"schema\": 1, \"packages\": []}"); @@ -257,9 +255,8 @@ public async Task TryReadAsync_Schema1Lockfile_ReturnsNull() [TestMethod] public async Task WriteAsync_UsesAtomicTempThenRename() { - // No reliable way to observe the tmp file mid-write in a unit test; - // verify post-conditions: final lockfile exists, no .tmp files left - // behind on a successful write. + // Can't observe mid-write reliably; check post-conditions: final + // file exists, no .tmp left behind. var winapp = _temp.CreateSubdirectory("winapp"); var cache = _temp.CreateSubdirectory("packages"); var winmd = MakeFile(cache, "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "AI.winmd"); @@ -279,6 +276,76 @@ await _svc.WriteAsync( $"No tmp staging file should remain after a successful write. Found: {string.Join(", ", entries)}"); } + [TestMethod] + public void PartitionFromLockfile_UncWinmdPaths_DroppedForBothEmitAndRefOnly() + { + // A tampered lockfile smuggling UNC paths must drop them for both + // emit and ref-only — any FileInfo() probe would SMB-handshake. + var cache = _temp.CreateSubdirectory("packages"); + var legitEmit = MakeFile(cache, "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); + var legitRef = MakeFile(cache, "microsoft.windowsappsdk.foundation", "1.8.0", "metadata", "Microsoft.Windows.Foundation.winmd"); + + // Hand-build the lockfile (BuildLockfile would reject paths outside + // the cache). RFC 2606 `.invalid` TLD never resolves. + var lockfile = new WinmdsLockfile + { + Schema = WinmdsLockfile.CurrentSchema, + GeneratedAt = DateTimeOffset.UtcNow.ToString("O"), + NugetCacheDir = cache.FullName, + YamlPackagesHash = "h", + Packages = new List + { + new WinmdsLockfilePackage + { + Name = "Microsoft.WindowsAppSDK.AI", + Version = "1.8.39", + Category = "emit", + Winmds = new List + { + legitEmit.FullName, + @"\\nonexistent-attacker.invalid\share\evil.emit.winmd", + }, + }, + new WinmdsLockfilePackage + { + Name = "Microsoft.WindowsAppSDK.Foundation", + Version = "1.8.0", + // Default category is Emit but no scope → demoted to RefOnly. + Category = "emit", + Winmds = new List + { + legitRef.FullName, + @"\\nonexistent-attacker.invalid\share\evil.ref.winmd", + }, + }, + }, + }; + + var sw = System.Diagnostics.Stopwatch.StartNew(); + var (emit, refOnly, _) = JsBindingsWorkspaceService.PartitionFromLockfile( + lockfile, _arr00); + sw.Stop(); + + Assert.IsTrue(sw.ElapsedMilliseconds < 2_000, + $"PartitionFromLockfile must drop UNC paths before any FileInfo probe " + + $"(took {sw.ElapsedMilliseconds}ms; >1s suggests SMB negotiation)."); + + Assert.AreEqual(1, emit.Count, "Only the legit emit winmd survives."); + Assert.IsTrue( + emit[0].FullName.EndsWith("Microsoft.Windows.AI.winmd", StringComparison.OrdinalIgnoreCase), + $"Emit must keep the legit local path. Got: {string.Join(", ", emit.Select(f => f.FullName))}"); + Assert.IsFalse( + emit.Any(f => f.FullName.Contains("nonexistent-attacker.invalid", StringComparison.OrdinalIgnoreCase)), + "Emit MUST drop the UNC entry."); + + Assert.AreEqual(1, refOnly.Count, "Only the legit ref-only winmd survives."); + Assert.IsTrue( + refOnly[0].FullName.EndsWith("Microsoft.Windows.Foundation.winmd", StringComparison.OrdinalIgnoreCase)); + Assert.IsFalse( + refOnly.Any(f => f.FullName.Contains("nonexistent-attacker.invalid", StringComparison.OrdinalIgnoreCase)), + "RefOnly MUST drop the UNC entry."); + } + private static FileInfo MakeFile(DirectoryInfo cache, params string[] segments) { var path = Path.Combine(new[] { cache.FullName }.Concat(segments).ToArray()); @@ -286,4 +353,107 @@ private static FileInfo MakeFile(DirectoryInfo cache, params string[] segments) File.WriteAllText(path, ""); return new FileInfo(path); } + + // --------------------------------------------------------------------- + // M9 — IsLockfilePathUnsafe rejects reparse-point ancestors silently + // --------------------------------------------------------------------- + + [TestMethod] + public async Task WriteAsync_WinappDirIsJunction_LogsAndSkipsWithoutWriting() + { + // The lockfile is an optimization, not correctness. When `.winapp/` + // is itself (or under) a junction/symlink, refuse to write rather + // than throw — otherwise a malicious workspace could redirect the + // write to a victim file. Skipping is still safe because codegen + // falls back to the full discovery path on next run. + var realDir = _temp.CreateSubdirectory("real-winapp"); + var winappJunction = Path.Combine(_temp.FullName, ".winapp"); + if (!TryCreateJunction(winappJunction, realDir.FullName)) + { + Assert.Inconclusive("Could not create a junction (CI may lack the privilege)."); + return; + } + + try + { + var winappDir = new DirectoryInfo(winappJunction); + var cache = _temp.CreateSubdirectory("packages"); + var winmd = MakeFile(cache, "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); + var usedVersions = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Microsoft.WindowsAppSDK.AI"] = "1.8.39", + }; + + await _svc.WriteAsync(winappDir, usedVersions, new[] { winmd }, cache, default); + + // Nothing inside the (junction-targeted) real dir AND nothing + // inside the junction view. Skip = no write. + Assert.IsFalse(File.Exists(Path.Combine(realDir.FullName, "winmds.lock.json")), + "Lockfile must NOT be written through a junctioned .winapp."); + } + finally + { + try { Directory.Delete(winappJunction, recursive: false); } catch { /* ignore */ } + } + } + + [TestMethod] + public async Task TryReadAsync_WinappDirIsJunction_ReturnsNullWithoutReading() + { + var realDir = _temp.CreateSubdirectory("real-winapp"); + // Plant a valid-looking lockfile under the REAL dir so the only + // way a read could succeed is by traversing the junction. + var lockfilePath = Path.Combine(realDir.FullName, "winmds.lock.json"); + await File.WriteAllTextAsync(lockfilePath, """ + { "schema": 2, "generated_at": "2025-01-01T00:00:00Z", "entries": [] } + """); + + var winappJunction = Path.Combine(_temp.FullName, ".winapp"); + if (!TryCreateJunction(winappJunction, realDir.FullName)) + { + Assert.Inconclusive("Could not create a junction (CI may lack the privilege)."); + return; + } + + try + { + var winappDir = new DirectoryInfo(winappJunction); + var result = await _svc.TryReadAsync(winappDir, default); + + Assert.IsNull(result, + "TryReadAsync must refuse to traverse a junctioned .winapp and return null."); + } + finally + { + try { Directory.Delete(winappJunction, recursive: false); } catch { /* ignore */ } + } + } + + // Junction creation helper (mklink /J — non-elevating on Windows). + private static bool TryCreateJunction(string link, string target) + { + try + { + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = "cmd.exe", + Arguments = $"/c mklink /J \"{link}\" \"{target}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + using var p = System.Diagnostics.Process.Start(psi); + if (p is null) + { + return false; + } + p.WaitForExit(5000); + return p.ExitCode == 0 && Directory.Exists(link); + } + catch + { + return false; + } + } } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/WorkspaceSetupServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/WorkspaceSetupServiceTests.cs index e1ad9270..d90eb487 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/WorkspaceSetupServiceTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/WorkspaceSetupServiceTests.cs @@ -192,6 +192,122 @@ public async Task SetupWorkspace_WithRequireExistingConfig_NoOpsWhenConfigMissin } } +// M2 (round-6): restore on a jsbindings-only workspace (packages: empty, +// jsBindings: declared) used to short-circuit at the empty-packages early- +// return, never regenerating bindings. Now it forwards to GenerateAsync. +[TestClass] +[DoNotParallelize] +public class WorkspaceSetupServiceJsBindingsOnlyRestoreTests : BaseCommandTests +{ + private FakeJsBindingsWorkspaceService _fakeJsBindings = null!; + + protected override IServiceCollection ConfigureServices(IServiceCollection services) + { + _fakeJsBindings = new FakeJsBindingsWorkspaceService(); + var existing = services.FirstOrDefault(d => d.ServiceType == typeof(IJsBindingsWorkspaceService)); + if (existing is not null) + { + services.Remove(existing); + } + services.AddSingleton(_fakeJsBindings); + return services; + } + + [TestMethod] + public async Task Restore_JsBindingsOnly_NoPackages_InvokesGenerateAsync() + { + // Yaml with only a jsBindings: block — no packages: at all. + // Pre-r6 the empty-packages early-return swallowed this case. + var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); + await File.WriteAllTextAsync(configPath, + "jsBindings:\n" + + " output: bindings/winrt\n" + + " lang: js\n"); + + var workspaceSetupService = GetRequiredService(); + var options = new WorkspaceSetupOptions + { + BaseDirectory = _tempDirectory, + ConfigDir = _tempDirectory, + RequireExistingConfig = true, + UseDefaults = true, + NoGitignore = true, + }; + + var exitCode = await workspaceSetupService.SetupWorkspaceAsync(options, TestContext.CancellationToken); + + Assert.AreEqual(0, exitCode, "Restore must succeed for jsbindings-only yaml."); + Assert.AreEqual(1, _fakeJsBindings.GenerateCalls.Count, + "Restore must call GenerateAsync exactly once when jsBindings: is present and packages: is empty."); + Assert.IsTrue(_fakeJsBindings.EnsureRuntimeDependencyCalled, + "Restore on jsbindings-only must also ensure @microsoft/dynwinrt is in package.json (parity with init --js-bindings)."); + } + + [TestMethod] + public async Task Restore_JsBindingsOnly_GenerateFailure_PropagatesNonZero() + { + var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); + await File.WriteAllTextAsync(configPath, + "jsBindings:\n" + + " output: bindings/winrt\n" + + " lang: js\n"); + + _fakeJsBindings.GenerateResult = 7; + + var workspaceSetupService = GetRequiredService(); + var options = new WorkspaceSetupOptions + { + BaseDirectory = _tempDirectory, + ConfigDir = _tempDirectory, + RequireExistingConfig = true, + UseDefaults = true, + NoGitignore = true, + }; + + var exitCode = await workspaceSetupService.SetupWorkspaceAsync(options, TestContext.CancellationToken); + + Assert.AreEqual(7, exitCode, "Restore must propagate GenerateAsync's non-zero exit code."); + Assert.IsFalse(_fakeJsBindings.EnsureRuntimeDependencyCalled, + "Runtime-dep injection must be skipped when generate fails (don't mutate package.json on a failed regen)."); + } + + [TestMethod] + public async Task Restore_EmptyYaml_NoPackagesNoJsBindings_DoesNotInvokeGenerateAsync() + { + // Pure no-op path: empty yaml, no jsBindings: — must NOT route through + // the jsbindings-only restore branch. The downstream SDK install flow + // is environment-dependent (cppwinrt is not available in the unit-test + // environment) and is covered by integration tests elsewhere, so this + // test only asserts the part that is mine to guard: GenerateAsync is + // not falsely triggered by the M2 short-circuit when JsBindings is + // null. + var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); + await File.WriteAllTextAsync(configPath, "# nothing here\n"); + + var workspaceSetupService = GetRequiredService(); + var options = new WorkspaceSetupOptions + { + BaseDirectory = _tempDirectory, + ConfigDir = _tempDirectory, + RequireExistingConfig = true, + UseDefaults = true, + NoGitignore = true, + }; + + try + { + await workspaceSetupService.SetupWorkspaceAsync(options, TestContext.CancellationToken); + } + catch + { + // Downstream cppwinrt / SDK install failures are out of scope here. + } + + Assert.AreEqual(0, _fakeJsBindings.GenerateCalls.Count, + "Empty yaml without jsBindings: must NOT trigger codegen."); + } +} + /// /// End-to-end tests for the merged .NET / native workspace setup. Verifies the /// unified WorkspaceSetupService handles both csproj and C++ projects through diff --git a/src/winapp-CLI/WinApp.Cli/Commands/InitCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/InitCommand.cs index 3777e29c..4b783bcc 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/InitCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/InitCommand.cs @@ -135,7 +135,7 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio var jsBindingsOutput = parseResult.GetValue(JsBindingsOutputOption); var jsBindingsLang = parseResult.GetValue(JsBindingsLangOption); - // Iteration order is alphabetical (registration order), so the + // Iteration order is alphabetical(registration order), so the // resulting prefix union is deterministic. var enabledAliases = JsBindingsPresetAliasOptions .Where(kv => parseResult.GetValue(kv.Value)) diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/PathSafety.cs b/src/winapp-CLI/WinApp.Cli/Helpers/PathSafety.cs new file mode 100644 index 00000000..1f4908f5 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Helpers/PathSafety.cs @@ -0,0 +1,240 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.IO; + +namespace WinApp.Cli.Helpers; + +// Shared filesystem-safety helpers. Centralizing the reparse-point / +// containment check keeps callers (ConfigService, UserPackageJsonService, +// JsBindingsWorkspaceService.WinmdDiscovery, WinmdsLockfileService) +// consistent — every "write into the user's workspace" site needs the +// same guard, and we don't want one to drift behind the others. +internal static class PathSafety +{ + // True if `path` is not safely contained under `boundary`, or if any + // segment from `boundary` down to `path` is a reparse point, or if + // either side is a UNC path. Used to refuse rewriting / probing files + // that a hostile workspace could redirect via a symlink/junction to a + // victim location elsewhere on the machine. + // + // Implementation notes: + // * Walks DOWN from `boundary` instead of UP from `path`. Walking up + // would force the OS to traverse any junctions / symlinks in `path` + // to look up the leaf's attributes, which on Windows can trigger + // SMB negotiation (and NTLM leak) before we ever see the + // reparse-point flag. Walking down lets us reject as soon as a + // suspicious segment is observed, without ever probing past it. + // * Uses `File.GetAttributes` rather than `FileInfo.Exists` / + // `DirectoryInfo.Exists`; the latter call FindFirstFile internally, + // which on a UNC ancestor would also probe the network before the + // reparse-point flag can be inspected. + // * UNC inputs are rejected outright (long-path `\\?\C:\…` is fine; + // `\\server\share` and `\\?\UNC\…` are not). + // * Missing segments are skipped (no I/O), so a caller about to + // create the file still passes the guard. + public static bool HasReparsePointOnPath(string path, string boundary) + { + string fullPath; + string fullBoundary; + try + { + fullPath = Path.GetFullPath(path); + fullBoundary = Path.GetFullPath(boundary); + } + catch + { + return true; + } + + if (IsNetworkPath(fullPath) || IsNetworkPath(fullBoundary)) + { + return true; + } + + var normalizedBoundary = NormalizeForContainment(fullBoundary); + var normalizedPath = NormalizeForContainment(fullPath); + + // Containment (string-only — no I/O). The boundary itself is a + // valid target (path == boundary), otherwise path must live under + // boundary + a separator. Boundary may already end in a separator + // (drive root, e.g. `C:\`) — don't double up. + bool isBoundaryItself = string.Equals( + normalizedPath, + normalizedBoundary, + StringComparison.OrdinalIgnoreCase); + var boundaryWithSep = normalizedBoundary.EndsWith(Path.DirectorySeparatorChar) + ? normalizedBoundary + : normalizedBoundary + Path.DirectorySeparatorChar; + bool isUnderBoundary = normalizedPath.StartsWith( + boundaryWithSep, + StringComparison.OrdinalIgnoreCase); + if (!isBoundaryItself && !isUnderBoundary) + { + return true; + } + + // Check the boundary itself FIRST. If the boundary is a reparse + // point, every descendant probe would silently follow it; refuse + // before we ever touch a descendant path. + if (TryGetAttributes(normalizedBoundary, out var boundaryAttr) + && boundaryAttr.HasFlag(FileAttributes.ReparsePoint)) + { + return true; + } + + if (isBoundaryItself) + { + return false; + } + + // Walk DOWN from boundary one segment at a time. The remainder + // after the boundary cannot contain `..` (Path.GetFullPath + // normalised it) so each segment is a literal directory / file + // name. + var remainder = normalizedPath.Substring(normalizedBoundary.Length); + var segments = remainder.Split( + new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, + StringSplitOptions.RemoveEmptyEntries); + + var current = normalizedBoundary; + foreach (var seg in segments) + { + current = Path.Combine(current, seg); + if (TryGetAttributes(current, out var segAttr) + && segAttr.HasFlag(FileAttributes.ReparsePoint)) + { + return true; + } + // Missing segments are fine — we don't refuse on absence. + } + + return false; + } + + // True for UNC / network paths (`\\server\share`, `\\?\UNC\…`, + // `\\.\UNC\…`). Local DOS device paths (`\\?\C:\…`) are not network. + // Centralized here so the reparse-point guard and JsBindings winmd + // discovery cannot drift apart — both share the same definition of + // "a path that would trigger an SMB probe / NTLM leak". + public static bool IsNetworkPath(string path) + { + if (string.IsNullOrEmpty(path)) + { + return false; + } + + var p = path.Replace('/', '\\'); + + // Plain UNC: \\server\share… (server is non-empty, not a device + // designator like '?' or '.'). + if (p.Length >= 3 + && p[0] == '\\' && p[1] == '\\' + && p[2] != '?' && p[2] != '.') + { + return true; + } + + // Device-prefixed UNC: \\?\UNC\server\… or \\.\UNC\server\… + if (p.Length >= 8 + && p[0] == '\\' && p[1] == '\\' + && (p[2] == '?' || p[2] == '.') + && p[3] == '\\' + && (p[4] == 'U' || p[4] == 'u') + && (p[5] == 'N' || p[5] == 'n') + && (p[6] == 'C' || p[6] == 'c') + && p[7] == '\\') + { + return true; + } + + return false; + } + + // Trims trailing separators but preserves the root separator for a + // bare drive designator. `C:\` would otherwise collapse to `C:` (a + // drive-relative reference) and the descent loop would then call + // Path.Combine("C:", seg) — yielding "C:foo" (drive-relative, resolved + // against the per-drive CWD) instead of "C:\foo". That silently + // bypasses the reparse-point check for any workspace/config-dir + // rooted at a drive letter. + private static string NormalizeForContainment(string path) + { + var trimmed = TrimTrailingSeparators(path); + if (trimmed.Length == 2 && trimmed[1] == ':') + { + return trimmed + Path.DirectorySeparatorChar; + } + return trimmed; + } + + private static string TrimTrailingSeparators(string path) => + path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + private static bool TryGetAttributes(string path, out FileAttributes attributes) + { + try + { + attributes = File.GetAttributes(path); + return true; + } + catch (FileNotFoundException) + { + attributes = default; + return false; + } + catch (DirectoryNotFoundException) + { + attributes = default; + return false; + } + catch + { + // Any other error (access denied, IO, etc.): treat as "no + // attributes available". Callers can still refuse the + // operation when they hit the actual read/write. + attributes = default; + return false; + } + } + + // Write `contents` to `path` atomically: stage to a sibling temp file + // (same volume so the move stays atomic), flush to disk, then rename + // over the destination. Prevents a crash / power loss mid-write from + // leaving the file truncated or empty. + public static void AtomicWriteAllText(string path, string contents, System.Text.Encoding encoding) + { + var dir = Path.GetDirectoryName(path); + if (string.IsNullOrEmpty(dir)) + { + dir = Directory.GetCurrentDirectory(); + } + var tmp = Path.Combine(dir, Path.GetFileName(path) + ".tmp-" + Guid.NewGuid().ToString("N")); + try + { + using (var fs = new FileStream(tmp, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + using (var sw = new StreamWriter(fs, encoding)) + { + sw.Write(contents); + sw.Flush(); + fs.Flush(flushToDisk: true); + } + File.Move(tmp, path, overwrite: true); + } + catch + { + try + { + if (File.Exists(tmp)) + { + File.Delete(tmp); + } + } + catch + { + // Best-effort cleanup; surface original error. + } + throw; + } + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/ConfigService.cs b/src/winapp-CLI/WinApp.Cli/Services/ConfigService.cs index 11cc03e6..e09ff991 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/ConfigService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/ConfigService.cs @@ -2,12 +2,19 @@ // Licensed under the MIT License. using System.Text; +using WinApp.Cli.Helpers; using WinApp.Cli.Models; namespace WinApp.Cli.Services; +// Thin file-I/O wrapper around WinappConfigDocument. The YAML grammar +// (parsing, splicing, rendering) lives in WinappConfigDocument so this +// service stays small and grammar evolutions don't leak into the +// service-surface tests. internal sealed class ConfigService : IConfigService { + private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); + public FileInfo ConfigPath { get; set; } public ConfigService(ICurrentDirectoryProvider currentDirectoryProvider) @@ -18,6 +25,11 @@ public ConfigService(ICurrentDirectoryProvider currentDirectoryProvider) public bool Exists() { + // Guard BEFORE probing the filesystem: ConfigPath.Exists internally + // hits FindFirstFile which on a symlinked / UNC ancestor would + // negotiate SMB (NTLM leak) before we could refuse. Run the + // string-only containment + reparse check first. + GuardConfigPath(); ConfigPath.Refresh(); return ConfigPath.Exists; } @@ -29,20 +41,23 @@ public WinappConfig Load() return new WinappConfig(); } + GuardConfigPath(); var text = File.ReadAllText(ConfigPath.FullName); - return Parse(text); + return WinappConfigDocument.Parse(text).Config; } public void Save(WinappConfig cfg) { + GuardConfigPath(); // Full serialization — drops comments / unknown fields. - var yaml = Stringify(cfg); - File.WriteAllText(ConfigPath.FullName, yaml, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + var yaml = new WinappConfigDocument(cfg).Render(); + PathSafety.AtomicWriteAllText(ConfigPath.FullName, yaml, Utf8NoBom); ConfigPath.Refresh(); } public void SaveJsBindingsOnly(WinappConfig cfg) { + GuardConfigPath(); string yaml; if (ConfigPath.Exists) { @@ -62,7 +77,7 @@ public void SaveJsBindingsOnly(WinappConfig cfg) try { - yaml = SpliceJsBindingsBlock(existing, cfg.JsBindings); + yaml = new WinappConfigDocument(cfg).SpliceJsBindingsInto(existing); } catch (Exception ex) { @@ -75,510 +90,30 @@ public void SaveJsBindingsOnly(WinappConfig cfg) } else { - yaml = Stringify(cfg); + yaml = new WinappConfigDocument(cfg).Render(); } - File.WriteAllText(ConfigPath.FullName, yaml, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + // Atomic write (temp + rename) so a crash mid-write can't leave + // winapp.yaml truncated. Pairs with reparse-point refusal above to + // make the path safe end-to-end. + PathSafety.AtomicWriteAllText(ConfigPath.FullName, yaml, Utf8NoBom); ConfigPath.Refresh(); } - // Splice a new jsBindings: block into existingYaml. Block bounds: a - // zero-indent "jsBindings:" line → next zero-indent non-blank line (or - // EOF). null `newJsBindings` removes the block. - internal static string SpliceJsBindingsBlock(string existingYaml, Models.JsBindingsConfig? newJsBindings) - { - string? replacement = null; - if (newJsBindings is not null) - { - var sb = new StringBuilder(); - AppendJsBindingsBlock(sb, newJsBindings); - replacement = sb.ToString(); - } - - // Line-by-line scan; preserve original newline style. - var lines = existingYaml.Split('\n'); - int blockStart = -1; - int blockEnd = -1; // exclusive end (next line index) - for (int i = 0; i < lines.Length; i++) - { - var trimmed = lines[i].TrimEnd('\r'); - if (IsTopLevelKey(trimmed, "jsBindings:")) - { - if (lines[i].Length > 0 && char.IsWhiteSpace(lines[i][0])) - { - continue; // nested, not a top-level key - } - blockStart = i; - // Find block end: next zero-indent non-blank line, or EOF. - // Zero-indent comments belong to the *next* top-level section - // (or to the file tail), not to jsBindings — preserve them. - blockEnd = lines.Length; - for (int j = i + 1; j < lines.Length; j++) - { - var t = lines[j].TrimEnd('\r'); - if (t.Length == 0) - { - continue; // blank lines belong to no block - } - if (!char.IsWhiteSpace(lines[j][0])) - { - // Any zero-indent line (key OR comment) ends the block. - blockEnd = j; - break; - } - } - break; - } - } - - if (blockStart >= 0) - { - var before = string.Join('\n', lines.Take(blockStart)); - var after = string.Join('\n', lines.Skip(blockEnd)); - var middle = replacement ?? string.Empty; - // Careful newline stitching — avoid double blanks / dropped trailing newline. - var result = new StringBuilder(); - if (before.Length > 0) - { - result.Append(before); - if (!before.EndsWith('\n')) - { - result.Append('\n'); - } - } - if (middle.Length > 0) - { - result.Append(middle); - if (!middle.EndsWith('\n')) - { - result.Append('\n'); - } - } - if (after.Length > 0) - { - result.Append(after); - } - return result.ToString(); - } - - // No existing block — append the new one (if any) at the end. - if (replacement is null) - { - return existingYaml; - } - var trailing = existingYaml.EndsWith('\n') ? string.Empty : "\n"; - return existingYaml + trailing + replacement; - } - - private static WinappConfig Parse(string yaml) - { - var cfg = new WinappConfig(); - using var sr = new StringReader(yaml); - string? line; - string? currentName = null; - var section = Section.None; - - // jsBindings sub-state - JsBindingsConfig? js = null; - var jsList = JsListMode.None; - JsBindingsExtraType? currentExtra = null; - bool inClassesList = false; - - while ((line = sr.ReadLine()) != null) - { - // Preserve raw indent for nested-list tracking, then trim for content match. - var indent = LeadingSpaceCount(line); - var t = line.Trim(); - if (t.StartsWith('#') || t.Length == 0) - { - continue; - } - - // Top-level section switches (no indent). - if (indent == 0) - { - if (t.Equals("packages:", StringComparison.OrdinalIgnoreCase)) - { - section = Section.Packages; - currentName = null; - continue; - } - // Accept `jsBindings:` followed by inline comment / trailing - // whitespace — matches SpliceJsBindingsBlock's detection so - // Load() and the splice can never disagree on whether the - // block exists. - if (IsTopLevelKey(t, "jsBindings:")) - { - section = Section.JsBindings; - js = new JsBindingsConfig(); - jsList = JsListMode.None; - currentExtra = null; - inClassesList = false; - continue; - } - - // Unknown top-level field → reset section so children don't leak. - section = Section.None; - currentName = null; - jsList = JsListMode.None; - currentExtra = null; - inClassesList = false; - continue; - } - - switch (section) - { - case Section.Packages: - if (t.StartsWith("- name:", StringComparison.OrdinalIgnoreCase)) - { - currentName = t.Substring("- name:".Length).Trim().Trim('"', '\''); - } - else if (t.StartsWith("name:", StringComparison.OrdinalIgnoreCase)) - { - currentName = t.Substring("name:".Length).Trim().Trim('"', '\''); - } - else if (t.StartsWith("version:", StringComparison.OrdinalIgnoreCase) && currentName is not null) - { - var version = t.Substring("version:".Length).Trim().Trim('"', '\''); - cfg.Packages.Add(new PackagePin { Name = currentName, Version = version }); - currentName = null; - } - break; - - case Section.JsBindings: - ParseJsBindingsLine(js!, t, ref jsList, ref currentExtra, ref inClassesList); - break; - } - } - - if (js is not null) - { - cfg.JsBindings = js; - } - return cfg; - } - - private static void ParseJsBindingsLine( - JsBindingsConfig js, - string t, - ref JsListMode listMode, - ref JsBindingsExtraType? currentExtra, - ref bool inClassesList) - { - // Scalar keys reset list state. - if (TryReadScalar(t, "lang:", out var v)) { js.Lang = v; listMode = JsListMode.None; currentExtra = null; inClassesList = false; return; } - if (TryReadScalar(t, "output:", out v)) { js.Output = v; listMode = JsListMode.None; currentExtra = null; inClassesList = false; return; } - - if (t.Equals("packages:", StringComparison.OrdinalIgnoreCase)) - { - listMode = JsListMode.Packages; - currentExtra = null; - inClassesList = false; - return; - } - if (t.Equals("additionalWinmds:", StringComparison.OrdinalIgnoreCase)) - { - listMode = JsListMode.AdditionalWinmds; - currentExtra = null; - inClassesList = false; - return; - } - if (t.Equals("additionalRefs:", StringComparison.OrdinalIgnoreCase)) - { - listMode = JsListMode.AdditionalRefs; - currentExtra = null; - inClassesList = false; - return; - } - if (t.Equals("skipPackages:", StringComparison.OrdinalIgnoreCase)) - { - listMode = JsListMode.SkipPackages; - currentExtra = null; - inClassesList = false; - return; - } - if (t.Equals("refOnlyPackages:", StringComparison.OrdinalIgnoreCase)) - { - listMode = JsListMode.RefOnlyPackages; - currentExtra = null; - inClassesList = false; - return; - } - if (t.Equals("emitPackages:", StringComparison.OrdinalIgnoreCase)) - { - listMode = JsListMode.EmitPackages; - currentExtra = null; - inClassesList = false; - return; - } - if (t.Equals("extraTypes:", StringComparison.OrdinalIgnoreCase)) - { - listMode = JsListMode.ExtraTypes; - currentExtra = null; - inClassesList = false; - return; - } - - if (listMode == JsListMode.Packages && t.StartsWith("- ", StringComparison.Ordinal)) - { - var pkg = t[2..].Trim().Trim('"', '\''); - if (!string.IsNullOrEmpty(pkg) - && !js.Packages.Contains(pkg, StringComparer.OrdinalIgnoreCase)) - { - js.Packages.Add(pkg); - } - return; - } - - if (listMode == JsListMode.AdditionalWinmds && t.StartsWith("- ", StringComparison.Ordinal)) - { - var path = t[2..].Trim().Trim('"', '\''); - if (!string.IsNullOrEmpty(path) - && !js.AdditionalWinmds.Contains(path, StringComparer.OrdinalIgnoreCase)) - { - js.AdditionalWinmds.Add(path); - } - return; - } - - if (listMode == JsListMode.AdditionalRefs && t.StartsWith("- ", StringComparison.Ordinal)) - { - var path = t[2..].Trim().Trim('"', '\''); - if (!string.IsNullOrEmpty(path) - && !js.AdditionalRefs.Contains(path, StringComparer.OrdinalIgnoreCase)) - { - js.AdditionalRefs.Add(path); - } - return; - } - - if (listMode == JsListMode.SkipPackages && t.StartsWith("- ", StringComparison.Ordinal)) - { - var pkg = t[2..].Trim().Trim('"', '\''); - if (!string.IsNullOrEmpty(pkg) - && !js.SkipPackages.Contains(pkg, StringComparer.OrdinalIgnoreCase)) - { - js.SkipPackages.Add(pkg); - } - return; - } - - if (listMode == JsListMode.RefOnlyPackages && t.StartsWith("- ", StringComparison.Ordinal)) - { - var pkg = t[2..].Trim().Trim('"', '\''); - if (!string.IsNullOrEmpty(pkg) - && !js.RefOnlyPackages.Contains(pkg, StringComparer.OrdinalIgnoreCase)) - { - js.RefOnlyPackages.Add(pkg); - } - return; - } - - if (listMode == JsListMode.EmitPackages && t.StartsWith("- ", StringComparison.Ordinal)) - { - var pkg = t[2..].Trim().Trim('"', '\''); - if (!string.IsNullOrEmpty(pkg) - && !js.EmitPackages.Contains(pkg, StringComparer.OrdinalIgnoreCase)) - { - js.EmitPackages.Add(pkg); - } - return; - } - - if (listMode == JsListMode.ExtraTypes) - { - // New item begins with `- namespace:` (the dash anchors a new entry). - if (t.StartsWith("- namespace:", StringComparison.OrdinalIgnoreCase)) - { - currentExtra = new JsBindingsExtraType - { - Namespace = t.Substring("- namespace:".Length).Trim().Trim('"', '\''), - }; - js.ExtraTypes.Add(currentExtra); - inClassesList = false; - return; - } - if (currentExtra is null) - { - return; - } - if (t.StartsWith("namespace:", StringComparison.OrdinalIgnoreCase)) - { - currentExtra.Namespace = t.Substring("namespace:".Length).Trim().Trim('"', '\''); - inClassesList = false; - return; - } - if (t.Equals("classes:", StringComparison.OrdinalIgnoreCase)) - { - inClassesList = true; - return; - } - // Inline flow-list form: `classes: [X, Y, Z]` or `classes: [X]`. - if (t.StartsWith("classes:", StringComparison.OrdinalIgnoreCase)) - { - var rest = t.Substring("classes:".Length).Trim(); - if (rest.StartsWith('[')) - { - var end = rest.IndexOf(']'); - if (end > 0) - { - var contents = rest.Substring(1, end - 1); - foreach (var item in contents.Split(',')) - { - var name = item.Trim().Trim('"', '\''); - if (!string.IsNullOrEmpty(name)) - { - currentExtra.Classes.Add(name); - } - } - inClassesList = false; - return; - } - } - // Scalar form: `classes: SingleClass` (no brackets). - if (!string.IsNullOrEmpty(rest)) - { - var name = rest.Trim('"', '\''); - currentExtra.Classes.Add(name); - inClassesList = false; - return; - } - } - if (inClassesList && t.StartsWith("- ", StringComparison.Ordinal)) - { - currentExtra.Classes.Add(t[2..].Trim().Trim('"', '\'')); - return; - } - } - } - - private static bool TryReadScalar(string t, string prefix, out string value) - { - if (t.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - { - value = t.Substring(prefix.Length).Trim().Trim('"', '\''); - return true; - } - value = string.Empty; - return false; - } - - private static int LeadingSpaceCount(string line) - { - int i = 0; - while (i < line.Length && line[i] == ' ') - { - i++; - } - return i; - } - - // Matches a top-level key like `packages:` or `jsBindings:` with any - // trailing whitespace or inline `# comment`. Used by both Parse and - // SpliceJsBindingsBlock so they never disagree on block presence. - private static bool IsTopLevelKey(string trimmedLine, string key) + // Refuse to read or rewrite winapp.yaml if the file (or any directory + // between it and its config-dir) is a symlink/junction — a malicious + // workspace could otherwise redirect the I/O at an arbitrary file on + // disk (e.g. clobbering a victim project's config). The config-dir + // itself is the containment boundary because `--config-dir` legitimately + // points outside the base workspace. + private void GuardConfigPath() { - if (!trimmedLine.StartsWith(key, StringComparison.OrdinalIgnoreCase)) + var boundary = ConfigPath.DirectoryName ?? Directory.GetCurrentDirectory(); + if (PathSafety.HasReparsePointOnPath(ConfigPath.FullName, boundary)) { - return false; - } - if (trimmedLine.Length == key.Length) - { - return true; - } - var rest = trimmedLine.AsSpan(key.Length).TrimStart(); - return rest.IsEmpty || rest[0] == '#'; - } - - private enum Section { None, Packages, JsBindings } - private enum JsListMode { None, Packages, ExtraTypes, AdditionalWinmds, AdditionalRefs, SkipPackages, RefOnlyPackages, EmitPackages } - - private static string Stringify(WinappConfig cfg) - { - var sb = new StringBuilder(); - sb.AppendLine("packages:"); - foreach (var p in cfg.Packages) - { - sb.AppendLine($" - name: {p.Name}"); - sb.AppendLine($" version: {p.Version}"); - } - - if (cfg.JsBindings is { } js) - { - sb.AppendLine(); - AppendJsBindingsBlock(sb, js); - } - return sb.ToString(); - } - - // Render the jsBindings: block. Shared by Stringify and SpliceJsBindingsBlock. - private static void AppendJsBindingsBlock(StringBuilder sb, Models.JsBindingsConfig js) - { - sb.AppendLine("jsBindings:"); - sb.AppendLine($" lang: {js.Lang}"); - sb.AppendLine($" output: {js.Output}"); - if (js.Packages.Count > 0) - { - sb.AppendLine(" packages:"); - foreach (var pkg in js.Packages) - { - sb.AppendLine($" - {pkg}"); - } - } - if (js.AdditionalWinmds.Count > 0) - { - sb.AppendLine(" additionalWinmds:"); - foreach (var path in js.AdditionalWinmds) - { - sb.AppendLine($" - {path}"); - } - } - if (js.AdditionalRefs.Count > 0) - { - sb.AppendLine(" additionalRefs:"); - foreach (var path in js.AdditionalRefs) - { - sb.AppendLine($" - {path}"); - } - } - if (js.SkipPackages.Count > 0) - { - sb.AppendLine(" skipPackages:"); - foreach (var pkg in js.SkipPackages) - { - sb.AppendLine($" - {pkg}"); - } - } - if (js.RefOnlyPackages.Count > 0) - { - sb.AppendLine(" refOnlyPackages:"); - foreach (var pkg in js.RefOnlyPackages) - { - sb.AppendLine($" - {pkg}"); - } - } - if (js.EmitPackages.Count > 0) - { - sb.AppendLine(" emitPackages:"); - foreach (var pkg in js.EmitPackages) - { - sb.AppendLine($" - {pkg}"); - } - } - if (js.ExtraTypes.Count > 0) - { - sb.AppendLine(" extraTypes:"); - foreach (var et in js.ExtraTypes) - { - sb.AppendLine($" - namespace: {et.Namespace}"); - if (et.Classes.Count > 0) - { - sb.AppendLine(" classes:"); - foreach (var cls in et.Classes) - { - sb.AppendLine($" - {cls}"); - } - } - } + throw new InvalidOperationException( + $"Refusing to access '{ConfigPath.FullName}': the file or one of its " + + "ancestors up to the config directory is a symbolic link / reparse " + + "point. Resolve the link and re-run."); } } -} +} \ No newline at end of file diff --git a/src/winapp-CLI/WinApp.Cli/Services/DynWinrtCodegenService.cs b/src/winapp-CLI/WinApp.Cli/Services/DynWinrtCodegenService.cs index 677efb40..d91f11c0 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/DynWinrtCodegenService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/DynWinrtCodegenService.cs @@ -47,11 +47,10 @@ public async Task RunAsync( var refWinmds = CollectRefWinmds(userAdditionalRefs, listedWinmds); // Locate codegen BEFORE touching the output dir so a missing install - // doesn't first wipe the user's previous bindings. Use the npm - // wrapper's pinned version for any error hints so they don't drift. + // doesn't first wipe the user's previous bindings. var versionHint = TryReadCodegenVersionHint(); - var (executable, prefixArgs) = ResolveCodegenInvocation(workspaceDir, versionHint); - logger.LogInformation( + var (executable, prefixArgs) = ResolveCodegenInvocation(versionHint); + logger.LogDebug( "{UISymbol} Resolved dynwinrt-codegen → {Executable} {PrefixArgs}", UiSymbols.Tools, executable, string.Join(' ', prefixArgs)); taskContext.AddDebugMessage($"{UiSymbols.Tools} Using codegen → {executable} {string.Join(' ', prefixArgs)}"); @@ -497,88 +496,110 @@ private async Task SpawnCodegenAsync( } } - // Walk parent dirs for node_modules/@microsoft/dynwinrt-codegen (Node.js - // bare-specifier resolution). Prefers the pre-built .exe; falls back to - // cli.js via a PATHEXT-resolved `node`. + // Resolves the dynwinrt-codegen binary winapp will spawn. Only the + // wrapper-bundled install is honored — workspace-local installs are + // never trusted (they could be substituted by a cloned/malicious repo). internal static (string Executable, List PrefixArgs) ResolveCodegenInvocation( - DirectoryInfo workspaceDir, + string? codegenVersionHint = null) + => ResolveCodegenInvocationCore(TryGetWrapperDir(), codegenVersionHint); + + // Test seam: inject wrapperDir directly. Production reads from + // Environment.ProcessPath (which under test points at testhost.exe). + internal static (string Executable, List PrefixArgs) ResolveCodegenInvocationCore( + DirectoryInfo? wrapperDir, string? codegenVersionHint = null) { var arch = ResolveArchSubdir(); DirectoryInfo? lastChecked = null; - // Search workspace ancestry first (user-installed override), then fall - // back to the wrapper's own node_modules near Environment.ProcessPath. - // pnpm / yarn-Berry layouts often place the codegen under the wrapper - // package rather than the workspace. - var roots = new List { workspaceDir }; - var exePath = Environment.ProcessPath; - if (!string.IsNullOrEmpty(exePath)) + if (wrapperDir is not null) { - var exeDir = Path.GetDirectoryName(exePath); - if (!string.IsNullOrEmpty(exeDir)) + var (wrapperHit, wrapperLastChecked) = TryFindCodegenIn(wrapperDir, arch); + if (wrapperHit is not null) { - var d = new DirectoryInfo(exeDir); - if (!string.Equals(d.FullName, workspaceDir.FullName, StringComparison.OrdinalIgnoreCase)) - { - roots.Add(d); - } + return wrapperHit.Value; } + lastChecked = wrapperLastChecked; } - foreach (var root in roots) + var wrapperLocationHint = wrapperDir is not null + ? $" at '{wrapperDir.FullName}'" + : " (winapp install directory could not be determined; try reinstalling @microsoft/winappcli)"; + + var partialInstallHint = lastChecked is not null + ? $"Found {CodegenPackageName} at '{lastChecked.FullName}' but no executable " + + $"inside (expected 'bin/{arch}/dynwinrt-codegen.exe' or 'cli.js'). " + + "The npm package may be corrupt; reinstall it.\n\n" + : $"Searched {CodegenPackageName} from the wrapper install{wrapperLocationHint} — " + + "no node_modules/@microsoft/dynwinrt-codegen found.\n\n"; + + var versionForHint = codegenVersionHint ?? CodegenPinnedVersionFallback; + + throw new InvalidOperationException( + partialInstallHint + + "To enable JS bindings, install via npm or yarn classic:\n" + + " npm i -D @microsoft/winappcli\n" + + $"(bundles {CodegenPackageName}@{versionForHint} as a transitive dependency.)\n\n" + + "Non-hoisting layouts (pnpm default, yarn-Berry PnP) are not supported: the\n" + + "codegen binary must live next to the winapp launcher so winapp can verify\n" + + "it ships the binary it's spawning. For pnpm, set 'node-linker=hoisted' in\n" + + ".npmrc; for yarn-Berry, set 'nodeLinker: node-modules' in .yarnrc.yml.\n\n" + + "See https://github.com/microsoft/WinAppCli#electron--nodejs for setup details."); + } + + // Walks up from `root` looking for node_modules/@microsoft/dynwinrt-codegen. + private static ( + (string Executable, List PrefixArgs)? Hit, + DirectoryInfo? LastChecked) + TryFindCodegenIn(DirectoryInfo root, string arch) + { + DirectoryInfo? lastChecked = null; + for (var probe = root; probe is not null; probe = probe.Parent) { - for (var probe = root; probe is not null; probe = probe.Parent) + var packageDir = Path.Combine(probe.FullName, "node_modules", "@microsoft", "dynwinrt-codegen"); + if (!Directory.Exists(packageDir)) { - var packageDir = Path.Combine(probe.FullName, "node_modules", "@microsoft", "dynwinrt-codegen"); - if (!Directory.Exists(packageDir)) - { - continue; - } - - // Priority 1: pre-built .exe (no Node startup needed). - var directExe = new FileInfo(Path.Combine(packageDir, "bin", arch, "dynwinrt-codegen.exe")); - if (directExe.Exists) - { - return (directExe.FullName, new List()); - } + continue; + } - // Priority 2: cli.js via node.exe (defensive fallback). - var localCli = new FileInfo(Path.Combine(packageDir, "cli.js")); - if (localCli.Exists) - { - // Reject .bat/.cmd/.ps1 — those go through cmd.exe parsing - // where user-derived args could be misinterpreted. Only - // spawn native executables (node.exe / node.com). - var nodePath = ResolveExecutableOnPath("node", nativeOnly: true) - ?? throw new InvalidOperationException( - $"The codegen at '{localCli.FullName}' requires a native Node.js executable " - + "(node.exe) on PATH. Install Node 18+ (winget install OpenJS.NodeJS) " - + $"or install {CodegenPackageName} so the pre-built .exe is available."); - return (nodePath, new List { localCli.FullName }); - } + // Priority 1: pre-built .exe (no Node startup needed). + var directExe = new FileInfo(Path.Combine(packageDir, "bin", arch, "dynwinrt-codegen.exe")); + if (directExe.Exists) + { + return ((directExe.FullName, new List()), null); + } - // Partial install (no exe + no cli.js); remember and keep walking. - lastChecked = new DirectoryInfo(packageDir); + // Priority 2: cli.js via node.exe (defensive fallback). + var localCli = new FileInfo(Path.Combine(packageDir, "cli.js")); + if (localCli.Exists) + { + // Reject .bat/.cmd/.ps1 — those go through cmd.exe parsing + // where user-derived args could be misinterpreted. + var nodePath = ResolveExecutableOnPath("node", nativeOnly: true) + ?? throw new InvalidOperationException( + $"The codegen at '{localCli.FullName}' requires a native Node.js executable " + + "(node.exe) on PATH. Install Node 18+ (winget install OpenJS.NodeJS) " + + $"or install {CodegenPackageName} so the pre-built .exe is available."); + return ((nodePath, new List { localCli.FullName }), null); } - } - var hint = lastChecked is not null - ? $"Found {CodegenPackageName} at '{lastChecked.FullName}' but no executable inside " - + $"(expected 'bin/{arch}/dynwinrt-codegen.exe' or 'cli.js'). The npm package may be corrupt; reinstall it.\n\n" - : $"Searched {CodegenPackageName} upward from '{workspaceDir.FullName}' and the wrapper install — no node_modules/@microsoft/dynwinrt-codegen found.\n\n"; + // Partial install (no exe + no cli.js); remember and keep walking. + lastChecked = new DirectoryInfo(packageDir); + } + return (null, lastChecked); + } - var versionForHint = codegenVersionHint ?? CodegenPinnedVersionFallback; - throw new InvalidOperationException( - hint - + "To enable JS bindings, install the codegen via one of:\n" - + " • npm/yarn classic/pnpm (default): npm i -D @microsoft/winappcli\n" - + " (bundles " + CodegenPackageName + " as a transitive dependency)\n" - + " • Install the codegen directly: npm i -D " - + CodegenPackageName + "@" + versionForHint + "\n" - + " • yarn berry (PnP): set 'nodeLinker: node-modules' in .yarnrc.yml, then yarn install\n" - + " • pnpm with isolated linker: set 'node-linker=hoisted' in .npmrc, then pnpm install\n\n" - + "See https://github.com/microsoft/WinAppCli#electron--nodejs for setup details."); + // Directory containing winapp.exe. Null when ProcessPath is empty + // (test / `dotnet run`). + private static DirectoryInfo? TryGetWrapperDir() + { + var exePath = Environment.ProcessPath; + if (string.IsNullOrEmpty(exePath)) + { + return null; + } + var dir = Path.GetDirectoryName(exePath); + return string.IsNullOrEmpty(dir) ? null : new DirectoryInfo(dir); } // Read the codegen version from the npm wrapper's package.json; falls diff --git a/src/winapp-CLI/WinApp.Cli/Services/JsBindingsWorkspaceService.RuntimeDependency.cs b/src/winapp-CLI/WinApp.Cli/Services/JsBindingsWorkspaceService.RuntimeDependency.cs new file mode 100644 index 00000000..7ef70989 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/JsBindingsWorkspaceService.RuntimeDependency.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using WinApp.Cli.Helpers; +using WinApp.Cli.Models; + +namespace WinApp.Cli.Services; + +// Adds @microsoft/dynwinrt to the user's package.json after codegen +// and prints an install hint. +internal sealed partial class JsBindingsWorkspaceService +{ + public void EnsureRuntimeDependencyAndPrintHint(DirectoryInfo workspaceDirectory) + { + const string DynWinrtPackageName = "@microsoft/dynwinrt"; + + string version; + try + { + version = npmWrapperVersionProvider.DynWinrtVersion; + } + catch (InvalidOperationException ex) + { + logger.LogWarning( + "{UISymbol} Could not resolve pinned {Package} version: {Reason}", + UiSymbols.Note, DynWinrtPackageName, ex.Message); + return; + } + + RuntimeDependencyOutcome outcome; + try + { + outcome = userPackageJsonService.EnsureRuntimeDependency( + workspaceDirectory, DynWinrtPackageName, version); + } + catch (InvalidOperationException ex) + { + logger.LogWarning( + "{UISymbol} Could not update package.json for {Package}: {Reason}. " + + "Add it manually to your dependencies.", + UiSymbols.Note, DynWinrtPackageName, ex.Message); + return; + } + + switch (outcome) + { + case RuntimeDependencyOutcome.Added: + var pmAdded = packageManagerDetector.Detect(workspaceDirectory); + // Info-level so --quiet suppresses; user runs the printed install cmd next. + logger.LogInformation( + "{UISymbol} Added {Package} @ {Version} to your package.json dependencies. Run `{InstallCmd}` to materialize it.", + UiSymbols.Check, DynWinrtPackageName, version, pmAdded.InstallCommand); + break; + case RuntimeDependencyOutcome.PresentInDevDependencies: + // Warning: production deploys (npm ci --omit=dev) will break. + logger.LogWarning( + "{UISymbol} {Package} is in devDependencies — generated bindings need it as a production dep. Move it manually.", + UiSymbols.Note, DynWinrtPackageName); + break; + case RuntimeDependencyOutcome.NoPackageJson: + logger.LogWarning( + "{UISymbol} No package.json found in workspace. Generated bindings will fail to resolve {Package} at runtime. Run `npm init -y` first.", + UiSymbols.Warning, DynWinrtPackageName); + break; + case RuntimeDependencyOutcome.AlreadyPresent: + default: + logger.LogInformation( + "{UISymbol} {Package} already declared in package.json dependencies — leaving it alone.", + UiSymbols.Check, DynWinrtPackageName); + break; + } + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/JsBindingsWorkspaceService.WinmdDiscovery.cs b/src/winapp-CLI/WinApp.Cli/Services/JsBindingsWorkspaceService.WinmdDiscovery.cs new file mode 100644 index 00000000..2e094eb4 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/JsBindingsWorkspaceService.WinmdDiscovery.cs @@ -0,0 +1,219 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using WinApp.Cli.ConsoleTasks; +using WinApp.Cli.Helpers; +using WinApp.Cli.Models; + +namespace WinApp.Cli.Services; + +// User-supplied winmd path validation (UNC guard, existence, dedupe) +// and live-mode NuGet transitive-dependency expansion. +internal sealed partial class JsBindingsWorkspaceService +{ + private List ResolveAdditionalWinmds( + List entries, + DirectoryInfo workspaceDir, + TaskContext taskContext, + string fieldName) + { + var resolved = new List(); + if (entries is null || entries.Count == 0) + { + return resolved; + } + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var entry in entries) + { + if (string.IsNullOrWhiteSpace(entry)) + { + continue; + } + var trimmed = entry.Trim(); + + // Reject UNC / network paths before any probe — FileInfo.Exists + // on a UNC triggers SMB negotiation and would leak the user's + // NTLM hash to the remote host. + if (PathSafety.IsNetworkPath(trimmed)) + { + taskContext.AddDebugMessage( + $"{UiSymbols.Warning} {fieldName} entry rejected as network/UNC path (refusing to probe): {entry}"); + logger.LogWarning( + "{UISymbol} jsBindings.{FieldName} entry refused — network/UNC paths are not allowed (would probe attacker-controlled host on FileInfo.Exists). Entry: {Entry}", + UiSymbols.Warning, + fieldName, + entry); + continue; + } + + var fullPath = Path.IsPathFullyQualified(trimmed) + ? Path.GetFullPath(trimmed) + : Path.GetFullPath(Path.Combine(workspaceDir.FullName, trimmed)); + + // Re-check after GetFullPath: a relative path under a UNC + // workspaceDir resolves to a UNC. + if (PathSafety.IsNetworkPath(fullPath)) + { + taskContext.AddDebugMessage( + $"{UiSymbols.Warning} {fieldName} entry resolved to a network/UNC path, rejected: {entry} → {fullPath}"); + logger.LogWarning( + "{UISymbol} jsBindings.{FieldName} entry resolved to UNC path; refusing to probe. Entry: {Entry} → {FullPath}", + UiSymbols.Warning, + fieldName, + entry, + fullPath); + continue; + } + + // Reparse-point guard: walk down from a boundary to fullPath + // and reject if any segment is a symlink/junction. Boundary + // selection: + // * Relative paths and absolute paths under the workspace — + // boundary = workspaceDir. Workspace containment is the + // natural trust scope. + // * Absolute paths outside the workspace — boundary = drive + // root (e.g. `C:\`). The user explicitly opted in to an + // out-of-workspace path (docs/js-bindings.md says absolute + // paths are supported); we still walk every segment for + // reparse points, but we don't force workspace containment. + // PathSafety.HasReparsePointOnPath already handles drive-root + // boundary correctly (see DriveRootBoundary_StillRejectsJunctionDescendant). + var underWorkspace = string.Equals(fullPath, workspaceDir.FullName, StringComparison.OrdinalIgnoreCase) + || fullPath.StartsWith( + workspaceDir.FullName.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase); + var reparseBoundary = underWorkspace + ? workspaceDir.FullName + : (Path.GetPathRoot(fullPath) ?? workspaceDir.FullName); + if (PathSafety.HasReparsePointOnPath(fullPath, reparseBoundary)) + { + taskContext.AddDebugMessage( + $"{UiSymbols.Warning} {fieldName} entry rejected — file or ancestor is a symlink/junction: {entry} → {fullPath}"); + logger.LogWarning( + "{UISymbol} jsBindings.{FieldName} entry refused — file or one of its ancestors up to {Boundary} is a reparse point. Entry: {Entry} → {FullPath}", + UiSymbols.Warning, + fieldName, + reparseBoundary, + entry, + fullPath); + continue; + } + + if (!seen.Add(fullPath)) + { + continue; + } + + var fi = new FileInfo(fullPath); + if (!fi.Exists) + { + taskContext.AddDebugMessage( + $"{UiSymbols.Note} {fieldName} entry not found, skipping: {entry}"); + logger.LogWarning( + "{UISymbol} jsBindings.{FieldName} entry not found, skipping: {Entry} (resolved to {FullPath})", + UiSymbols.Note, + fieldName, + entry, + fullPath); + continue; + } + + resolved.Add(fi); + } + + return resolved; + } + + // Count extraTypes entries codegen would actually process; entries with + // a blank namespace or no classes are silently skipped. + internal static int CountValidExtraTypes(IReadOnlyList extraTypes) + { + if (extraTypes is null) + { + return 0; + } + var count = 0; + foreach (var et in extraTypes) + { + if (!string.IsNullOrWhiteSpace(et.Namespace) && et.Classes.Count > 0) + { + count++; + } + } + return count; + } + + // (UNC / network-path detector lives on PathSafety so the reparse-point + // guard and winmd discovery share the same definition — see + // PathSafety.IsNetworkPath.) + + internal async Task> ExpandTransitiveDependenciesAsync( + Dictionary usedVersions, + TaskContext taskContext, + CancellationToken cancellationToken) + { + var expanded = new Dictionary(usedVersions, StringComparer.OrdinalIgnoreCase); + var roots = usedVersions.ToList(); + var failures = new List<(string Package, string Version, string Reason)>(); + foreach (var (name, version) in roots) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var deps = await nugetService.GetPackageDependenciesAsync(name, version, cancellationToken); + foreach (var (depId, depVersionSpec) in deps) + { + var depVersion = NugetService.ParseMinimumVersion(depVersionSpec); + if (string.IsNullOrEmpty(depVersion)) + { + continue; + } + if (!expanded.ContainsKey(depId)) + { + expanded[depId] = depVersion; + } + } + } + catch (OperationCanceledException) + { + // User cancellation must propagate — never swallow. + throw; + } + catch (Exception ex) + { + // Record but keep going; surface a single warning at the + // end so users know bindings may be incomplete. + failures.Add((name, version, ex.Message)); + taskContext.AddDebugMessage( + $"{UiSymbols.Note} Could not expand transitive deps for {name} {version}: {ex.Message}"); + logger.LogDebug(ex, + "Transitive dependency expansion failed for {PackageName} {Version}", name, version); + } + } + + if (failures.Count > 0) + { + var summary = string.Join(", ", + failures.Take(5).Select(f => $"{f.Package} {f.Version}")); + var ellipsis = failures.Count > 5 ? $" (+ {failures.Count - 5} more)" : string.Empty; + logger.LogWarning( + "{UISymbol} Could not resolve transitive NuGet dependencies for {Count} package(s): {Packages}{Ellipsis}. " + + "Generated bindings may be incomplete (missing referenced types). " + + "Run `winapp restore` to materialize the full dependency graph and try again.", + UiSymbols.Warning, + failures.Count, + summary, + ellipsis); + taskContext.AddDebugMessage( + $"{UiSymbols.Warning} Transitive expansion failures ({failures.Count}):"); + foreach (var f in failures) + { + taskContext.AddDebugMessage($" - {f.Package} {f.Version}: {f.Reason}"); + } + } + + return expanded; + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/JsBindingsWorkspaceService.cs b/src/winapp-CLI/WinApp.Cli/Services/JsBindingsWorkspaceService.cs index ce0c709c..b9a84cb3 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/JsBindingsWorkspaceService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/JsBindingsWorkspaceService.cs @@ -10,9 +10,9 @@ namespace WinApp.Cli.Services; -// Default IJsBindingsWorkspaceService — composes the single-purpose -// services into the end-to-end pipeline described on the interface. -internal sealed class JsBindingsWorkspaceService( +// Default IJsBindingsWorkspaceService — orchestration entrypoint. +// Split into partials: .WinmdDiscovery.cs and .RuntimeDependency.cs. +internal sealed partial class JsBindingsWorkspaceService( IPackageLayoutService packageLayoutService, IWinmdsLockfileService winmdsLockfileService, IDynWinrtCodegenService dynWinrtCodegenService, @@ -150,7 +150,11 @@ public async Task RunAsync( { taskContext.AddDebugMessage($"{UiSymbols.Note} JS binding generation failed: {ex.Message}"); logger.LogDebug(ex, "JS binding generation failed"); - return new JsBindingsOrchestrationResult { ExitCode = 1, Message = "JS binding generation failed." }; + return new JsBindingsOrchestrationResult + { + ExitCode = 1, + Message = $"JS binding generation failed: {ex.Message}", + }; } } @@ -328,7 +332,7 @@ internal static (List Emit, List RefOnly, int SkippedCount) { // Drop UNC paths so a tampered lockfile can't // trigger credential-leaking SMB probes downstream. - if (IsNetworkPath(path)) + if (PathSafety.IsNetworkPath(path)) { continue; } @@ -338,7 +342,7 @@ internal static (List Emit, List RefOnly, int SkippedCount) default: foreach (var path in pkg.Winmds) { - if (IsNetworkPath(path)) + if (PathSafety.IsNetworkPath(path)) { continue; } @@ -376,242 +380,6 @@ internal static List MergeRefWinmds( return result; } - internal async Task> ExpandTransitiveDependenciesAsync( - Dictionary usedVersions, - TaskContext taskContext, - CancellationToken cancellationToken) - { - var expanded = new Dictionary(usedVersions, StringComparer.OrdinalIgnoreCase); - var roots = usedVersions.ToList(); - foreach (var (name, version) in roots) - { - try - { - var deps = await nugetService.GetPackageDependenciesAsync(name, version, cancellationToken); - foreach (var (depId, depVersionSpec) in deps) - { - var depVersion = NugetService.ParseMinimumVersion(depVersionSpec); - if (string.IsNullOrEmpty(depVersion)) - { - continue; - } - if (!expanded.ContainsKey(depId)) - { - expanded[depId] = depVersion; - } - } - } - catch (Exception ex) - { - taskContext.AddDebugMessage( - $"{UiSymbols.Note} Could not expand transitive deps for {name} {version}: {ex.Message}"); - logger.LogDebug(ex, - "Transitive dependency expansion failed for {PackageName} {Version}", name, version); - } - } - return expanded; - } - - private List ResolveAdditionalWinmds( - List entries, - DirectoryInfo workspaceDir, - TaskContext taskContext, - string fieldName) - { - var resolved = new List(); - if (entries is null || entries.Count == 0) - { - return resolved; - } - - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var entry in entries) - { - if (string.IsNullOrWhiteSpace(entry)) - { - continue; - } - var trimmed = entry.Trim(); - - // Reject UNC / network paths before any probe — FileInfo.Exists - // on a UNC triggers SMB negotiation and would leak the user's - // NTLM hash to the remote host. - if (IsNetworkPath(trimmed)) - { - taskContext.AddDebugMessage( - $"{UiSymbols.Warning} {fieldName} entry rejected as network/UNC path (refusing to probe): {entry}"); - logger.LogWarning( - "{UISymbol} jsBindings.{FieldName} entry refused — network/UNC paths are not allowed (would probe attacker-controlled host on FileInfo.Exists). Entry: {Entry}", - UiSymbols.Warning, - fieldName, - entry); - continue; - } - - var fullPath = Path.IsPathFullyQualified(trimmed) - ? Path.GetFullPath(trimmed) - : Path.GetFullPath(Path.Combine(workspaceDir.FullName, trimmed)); - - // Second guard: after Path.GetFullPath the resolved form might - // still be a UNC (e.g. workspaceDir itself on a network share - // joined with a relative `..\\..\\share\\evil`). - if (IsNetworkPath(fullPath)) - { - taskContext.AddDebugMessage( - $"{UiSymbols.Warning} {fieldName} entry resolved to a network/UNC path, rejected: {entry} → {fullPath}"); - logger.LogWarning( - "{UISymbol} jsBindings.{FieldName} entry resolved to UNC path; refusing to probe. Entry: {Entry} → {FullPath}", - UiSymbols.Warning, - fieldName, - entry, - fullPath); - continue; - } - - if (!seen.Add(fullPath)) - { - continue; - } - - var fi = new FileInfo(fullPath); - if (!fi.Exists) - { - taskContext.AddDebugMessage( - $"{UiSymbols.Note} {fieldName} entry not found, skipping: {entry}"); - logger.LogWarning( - "{UISymbol} jsBindings.{FieldName} entry not found, skipping: {Entry} (resolved to {FullPath})", - UiSymbols.Note, - fieldName, - entry, - fullPath); - continue; - } - - resolved.Add(fi); - } - - return resolved; - } - - // Count extraTypes entries codegen would actually process; entries with - // a blank namespace or no classes are silently skipped. - internal static int CountValidExtraTypes(IReadOnlyList extraTypes) - { - if (extraTypes is null) - { - return 0; - } - var count = 0; - foreach (var et in extraTypes) - { - if (!string.IsNullOrWhiteSpace(et.Namespace) && et.Classes.Count > 0) - { - count++; - } - } - return count; - } - - // True if `path` looks like a UNC / network location (plain `\\server\share`, - // long-path UNC `\\?\UNC\…`, or device UNC `\\.\UNC\…`). Local DOS device - // paths (`\\?\C:\…`) are NOT classified as network. Used to refuse probing - // attacker-controlled paths via FileInfo.Exists. - internal static bool IsNetworkPath(string path) - { - if (string.IsNullOrEmpty(path)) - { - return false; - } - - // Normalize separators to '\' for the prefix tests. - var p = path.Replace('/', '\\'); - - // Plain UNC: \\server\share… (server is non-empty, not a device - // designator like '?' or '.'). - if (p.Length >= 3 - && p[0] == '\\' && p[1] == '\\' - && p[2] != '?' && p[2] != '.') - { - return true; - } - - // Device-prefixed UNC: \\?\UNC\server\… or \\.\UNC\server\… - if (p.Length >= 8 - && p[0] == '\\' && p[1] == '\\' - && (p[2] == '?' || p[2] == '.') - && p[3] == '\\' - && (p[4] == 'U' || p[4] == 'u') - && (p[5] == 'N' || p[5] == 'n') - && (p[6] == 'C' || p[6] == 'c') - && p[7] == '\\') - { - return true; - } - - return false; - } - - public void EnsureRuntimeDependencyAndPrintHint(DirectoryInfo workspaceDirectory) - { - const string DynWinrtPackageName = "@microsoft/dynwinrt"; - - string version; - try - { - version = npmWrapperVersionProvider.DynWinrtVersion; - } - catch (InvalidOperationException ex) - { - logger.LogWarning( - "{UISymbol} Could not resolve pinned {Package} version: {Reason}", - UiSymbols.Note, DynWinrtPackageName, ex.Message); - return; - } - - RuntimeDependencyOutcome outcome; - try - { - outcome = userPackageJsonService.EnsureRuntimeDependency( - workspaceDirectory, DynWinrtPackageName, version); - } - catch (InvalidOperationException ex) - { - logger.LogWarning( - "{UISymbol} Could not update package.json for {Package}: {Reason}. " + - "Add it manually to your dependencies.", - UiSymbols.Note, DynWinrtPackageName, ex.Message); - return; - } - - switch (outcome) - { - case RuntimeDependencyOutcome.Added: - var pmAdded = packageManagerDetector.Detect(workspaceDirectory); - // Info-level so --quiet suppresses; user runs the printed install cmd next. - logger.LogInformation( - "{UISymbol} Added {Package} @ {Version} to your package.json dependencies. Run `{InstallCmd}` to materialize it.", - UiSymbols.Check, DynWinrtPackageName, version, pmAdded.InstallCommand); - break; - case RuntimeDependencyOutcome.PresentInDevDependencies: - // Warning: production deploys (npm ci --omit=dev) will break. - logger.LogWarning( - "{UISymbol} {Package} is in devDependencies — generated bindings need it as a production dep. Move it manually.", - UiSymbols.Note, DynWinrtPackageName); - break; - case RuntimeDependencyOutcome.NoPackageJson: - logger.LogWarning( - "{UISymbol} No package.json found in workspace. Generated bindings will fail to resolve {Package} at runtime. Run `npm init -y` first.", - UiSymbols.Warning, DynWinrtPackageName); - break; - case RuntimeDependencyOutcome.AlreadyPresent: - default: - logger.LogInformation( - "{UISymbol} {Package} already declared in package.json dependencies — leaving it alone.", - UiSymbols.Check, DynWinrtPackageName); - break; - } - } - public async Task AddAsync(AddJsBindingsOptions options, CancellationToken cancellationToken = default) { configService.ConfigPath = new FileInfo(Path.Combine(options.ConfigDir.FullName, "winapp.yaml")); @@ -619,7 +387,8 @@ public async Task AddAsync(AddJsBindingsOptions options, CancellationToken if (!configService.Exists()) { logger.LogError( - "{UISymbol} winapp.yaml not found at {ConfigPath}. Run 'npx winapp init' first to bootstrap a workspace.", + "{UISymbol} winapp.yaml not found at {ConfigPath}. Run 'npx winapp init' first to bootstrap a workspace. " + + "Tip: --config-dir resolves relative to the current directory — verify it points to the same workspace 'init' targeted.", UiSymbols.Error, configService.ConfigPath.FullName); return 1; @@ -815,7 +584,8 @@ public async Task GenerateAsync(GenerateJsBindingsOptions options, Cancella if (!configService.Exists()) { logger.LogError( - "{UISymbol} winapp.yaml not found at {ConfigPath}. Run 'npx winapp init' first to bootstrap a workspace.", + "{UISymbol} winapp.yaml not found at {ConfigPath}. Run 'npx winapp init' first to bootstrap a workspace. " + + "Tip: --config-dir resolves relative to the current directory — verify it points to the same workspace 'init' targeted.", UiSymbols.Error, configService.ConfigPath.FullName); return 1; @@ -870,6 +640,12 @@ public async Task GenerateAsync(GenerateJsBindingsOptions options, Cancella LocalWinappDir = localWinappDir, NugetCacheDir = nugetCacheDir, UsedVersions = null, + // Read-only contract: `node jsbindings generate` is + // documented as a no-op on yaml AND package.json. + // The runtime dep is added by `node jsbindings add` + // and by `init --js-bindings`; re-adding it here + // would silently un-do a deliberate user removal. + EnsureRuntimeDependency = false, }, taskContext, ct); diff --git a/src/winapp-CLI/WinApp.Cli/Services/UserPackageJsonService.cs b/src/winapp-CLI/WinApp.Cli/Services/UserPackageJsonService.cs index 2689a9ff..23fa757c 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/UserPackageJsonService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/UserPackageJsonService.cs @@ -5,13 +5,17 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text; using System.Text.Json; using System.Text.Json.Nodes; +using WinApp.Cli.Helpers; namespace WinApp.Cli.Services; internal sealed class UserPackageJsonService : IUserPackageJsonService { + private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); + public RuntimeDependencyOutcome EnsureRuntimeDependency( DirectoryInfo workspaceDirectory, string packageName, @@ -22,6 +26,21 @@ public RuntimeDependencyOutcome EnsureRuntimeDependency( ArgumentException.ThrowIfNullOrWhiteSpace(version); var packageJsonPath = Path.Combine(workspaceDirectory.FullName, "package.json"); + + // Refuse symlinks/junctions and reparse-point ancestors BEFORE any + // probe. `File.Exists` internally calls FindFirstFile which on a + // reparse-point ancestor would silently follow the link (and on a + // UNC ancestor would trigger SMB negotiation / NTLM leak) before + // we got a chance to refuse. Run the non-probing string + attribute + // check first. + if (PathSafety.HasReparsePointOnPath(packageJsonPath, workspaceDirectory.FullName)) + { + throw new InvalidOperationException( + $"Refusing to rewrite '{packageJsonPath}': the file or one of its " + + "ancestors is a symbolic link / reparse point. Resolve the link " + + "and re-run, or add the runtime dependency manually."); + } + if (!File.Exists(packageJsonPath)) { return RuntimeDependencyOutcome.NoPackageJson; @@ -40,6 +59,16 @@ public RuntimeDependencyOutcome EnsureRuntimeDependency( throw new InvalidOperationException( $"Failed to parse {packageJsonPath}: {ex.Message}", ex); } + catch (IOException ex) + { + throw new InvalidOperationException( + $"Failed to read {packageJsonPath}: {ex.Message}", ex); + } + catch (UnauthorizedAccessException ex) + { + throw new InvalidOperationException( + $"No permission to read {packageJsonPath}: {ex.Message}", ex); + } if (root is not JsonObject obj) { @@ -76,13 +105,28 @@ public RuntimeDependencyOutcome EnsureRuntimeDependency( var serialized = root.ToJsonString(options); // Preserve trailing newline if the original had one. - var original = File.ReadAllText(packageJsonPath); - if (original.EndsWith('\n') && !serialized.EndsWith('\n')) + try { - serialized += '\n'; - } + var original = File.ReadAllText(packageJsonPath); + if (original.EndsWith('\n') && !serialized.EndsWith('\n')) + { + serialized += '\n'; + } - File.WriteAllText(packageJsonPath, serialized); + // Atomic write: stage to sibling temp + rename so a crash mid-write + // cannot leave the user with an invalid / empty package.json. + PathSafety.AtomicWriteAllText(packageJsonPath, serialized, Utf8NoBom); + } + catch (IOException ex) + { + throw new InvalidOperationException( + $"Failed to write {packageJsonPath}: {ex.Message}", ex); + } + catch (UnauthorizedAccessException ex) + { + throw new InvalidOperationException( + $"No permission to write {packageJsonPath}: {ex.Message}", ex); + } return RuntimeDependencyOutcome.Added; } diff --git a/src/winapp-CLI/WinApp.Cli/Services/WinappConfigDocument.cs b/src/winapp-CLI/WinApp.Cli/Services/WinappConfigDocument.cs new file mode 100644 index 00000000..ad2a1e0b --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/WinappConfigDocument.cs @@ -0,0 +1,696 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Text; +using WinApp.Cli.Models; + +namespace WinApp.Cli.Services; + +/// +/// Hand-rolled reader / writer / splicer for the small winapp.yaml grammar +/// (packages + jsBindings only). Pure data class — no DI, no file I/O. +/// +/// Mirrors : is +/// the thin file-I/O wrapper; this class owns the YAML grammar. Splitting +/// keeps grammar changes (which evolve with the schema) from leaking into +/// the service surface tests assert against. +/// +internal sealed class WinappConfigDocument +{ + public WinappConfig Config { get; } + + public WinappConfigDocument(WinappConfig config) + { + Config = config ?? throw new ArgumentNullException(nameof(config)); + } + + /// + /// Parse a winapp.yaml document. Unknown top-level fields are silently + /// ignored so adding fields server-side doesn't break older CLIs. + /// + public static WinappConfigDocument Parse(string yaml) + { + return new WinappConfigDocument(ParseInternal(yaml ?? string.Empty)); + } + + /// + /// Full re-serialization. Drops comments and unknown fields — use + /// when you need to preserve the + /// rest of the file. + /// + public string Render() => Stringify(Config); + + /// + /// Replace (or insert) just the jsBindings: block inside the existing + /// yaml text, preserving comments, unknown fields, blank lines, and + /// original line endings. Returns the rewritten yaml text. + /// + public string SpliceJsBindingsInto(string existingYaml) + => SpliceJsBindingsBlock(existingYaml ?? string.Empty, Config.JsBindings); + + // ------------------------------------------------------------------------- + // Splice + // ------------------------------------------------------------------------- + + // Splice a new jsBindings: block into existingYaml. Block bounds: a + // zero-indent "jsBindings:" line → next zero-indent non-blank line (or + // EOF). null `newJsBindings` removes the block. + internal static string SpliceJsBindingsBlock(string existingYaml, JsBindingsConfig? newJsBindings) + { + string? replacement = null; + if (newJsBindings is not null) + { + var sb = new StringBuilder(); + AppendJsBindingsBlock(sb, newJsBindings); + replacement = sb.ToString(); + } + + // Line-by-line scan; preserve original newline style. + var lines = existingYaml.Split('\n'); + int blockStart = -1; + int blockEnd = -1; // exclusive end (next line index) + for (int i = 0; i < lines.Length; i++) + { + var trimmed = lines[i].TrimEnd('\r'); + if (IsTopLevelKey(trimmed, "jsBindings:")) + { + if (lines[i].Length > 0 && char.IsWhiteSpace(lines[i][0])) + { + continue; // nested, not a top-level key + } + blockStart = i; + // Find block end: next zero-indent non-blank line, or EOF. + // Zero-indent comments belong to the *next* top-level section + // (or to the file tail), not to jsBindings — preserve them. + blockEnd = lines.Length; + for (int j = i + 1; j < lines.Length; j++) + { + var t = lines[j].TrimEnd('\r'); + if (t.Length == 0) + { + continue; // blank lines belong to no block + } + if (!char.IsWhiteSpace(lines[j][0])) + { + // Any zero-indent line (key OR comment) ends the block. + blockEnd = j; + break; + } + } + break; + } + } + + if (blockStart >= 0) + { + var before = string.Join('\n', lines.Take(blockStart)); + var after = string.Join('\n', lines.Skip(blockEnd)); + var middle = replacement ?? string.Empty; + // Careful newline stitching — avoid double blanks / dropped trailing newline. + var result = new StringBuilder(); + if (before.Length > 0) + { + result.Append(before); + if (!before.EndsWith('\n')) + { + result.Append('\n'); + } + } + if (middle.Length > 0) + { + result.Append(middle); + if (!middle.EndsWith('\n')) + { + result.Append('\n'); + } + } + if (after.Length > 0) + { + result.Append(after); + } + return result.ToString(); + } + + // No existing block — append the new one (if any) at the end. + if (replacement is null) + { + return existingYaml; + } + var trailing = existingYaml.EndsWith('\n') ? string.Empty : "\n"; + return existingYaml + trailing + replacement; + } + + // ------------------------------------------------------------------------- + // Parse + // ------------------------------------------------------------------------- + + private static WinappConfig ParseInternal(string yaml) + { + var cfg = new WinappConfig(); + using var sr = new StringReader(yaml); + string? line; + string? currentName = null; + var section = Section.None; + + // jsBindings sub-state + JsBindingsConfig? js = null; + var jsList = JsListMode.None; + JsBindingsExtraType? currentExtra = null; + bool inClassesList = false; + + while ((line = sr.ReadLine()) != null) + { + // Preserve raw indent for nested-list tracking, then trim for content match. + var indent = LeadingSpaceCount(line); + var t = line.Trim(); + if (t.StartsWith('#') || t.Length == 0) + { + continue; + } + + // Top-level section switches (no indent). + if (indent == 0) + { + if (IsTopLevelKey(t, "packages:")) + { + section = Section.Packages; + currentName = null; + continue; + } + // Accept `jsBindings:` followed by inline comment / trailing + // whitespace — matches SpliceJsBindingsBlock's detection so + // Load() and the splice can never disagree on whether the + // block exists. + if (IsTopLevelKey(t, "jsBindings:")) + { + section = Section.JsBindings; + js = new JsBindingsConfig(); + jsList = JsListMode.None; + currentExtra = null; + inClassesList = false; + continue; + } + + // Unknown top-level field → reset section so children don't leak. + section = Section.None; + currentName = null; + jsList = JsListMode.None; + currentExtra = null; + inClassesList = false; + continue; + } + + switch (section) + { + case Section.Packages: + if (t.StartsWith("- name:", StringComparison.OrdinalIgnoreCase)) + { + currentName = SanitizeScalar(t.Substring("- name:".Length)); + } + else if (t.StartsWith("name:", StringComparison.OrdinalIgnoreCase)) + { + currentName = SanitizeScalar(t.Substring("name:".Length)); + } + else if (currentName is not null && t.StartsWith("version:", StringComparison.OrdinalIgnoreCase)) + { + var version = SanitizeScalar(t.Substring("version:".Length)); + cfg.Packages.Add(new PackagePin { Name = currentName, Version = version }); + currentName = null; + } + break; + + case Section.JsBindings: + ParseJsBindingsLine(js!, t, ref jsList, ref currentExtra, ref inClassesList); + break; + } + } + + if (js is not null) + { + cfg.JsBindings = js; + } + return cfg; + } + + private static void ParseJsBindingsLine( + JsBindingsConfig js, + string t, + ref JsListMode listMode, + ref JsBindingsExtraType? currentExtra, + ref bool inClassesList) + { + // Scalar keys reset list state. + if (TryReadScalar(t, "lang:", out var v)) { js.Lang = v; listMode = JsListMode.None; currentExtra = null; inClassesList = false; return; } + if (TryReadScalar(t, "output:", out v)) { js.Output = v; listMode = JsListMode.None; currentExtra = null; inClassesList = false; return; } + + if (IsTopLevelKey(t, "packages:")) + { + listMode = JsListMode.Packages; + currentExtra = null; + inClassesList = false; + return; + } + if (IsTopLevelKey(t, "additionalWinmds:")) + { + listMode = JsListMode.AdditionalWinmds; + currentExtra = null; + inClassesList = false; + return; + } + if (IsTopLevelKey(t, "additionalRefs:")) + { + listMode = JsListMode.AdditionalRefs; + currentExtra = null; + inClassesList = false; + return; + } + if (IsTopLevelKey(t, "skipPackages:")) + { + listMode = JsListMode.SkipPackages; + currentExtra = null; + inClassesList = false; + return; + } + if (IsTopLevelKey(t, "refOnlyPackages:")) + { + listMode = JsListMode.RefOnlyPackages; + currentExtra = null; + inClassesList = false; + return; + } + if (IsTopLevelKey(t, "emitPackages:")) + { + listMode = JsListMode.EmitPackages; + currentExtra = null; + inClassesList = false; + return; + } + if (IsTopLevelKey(t, "extraTypes:")) + { + listMode = JsListMode.ExtraTypes; + currentExtra = null; + inClassesList = false; + return; + } + + if (t.StartsWith("- ", StringComparison.Ordinal) + && s_listSelectors.TryGetValue(listMode, out var getList)) + { + var value = SanitizeScalar(t[2..]); + var list = getList(js); + if (!string.IsNullOrEmpty(value) + && !list.Contains(value, StringComparer.OrdinalIgnoreCase)) + { + list.Add(value); + } + return; + } + + if (listMode == JsListMode.ExtraTypes) + { + // A `- ` IMMEDIATELY followed by a known extraTypes sub-key + // (`namespace:` or `classes:`) anchors a new entry. Without + // this recognition rule we couldn't tell `- classes:` (a new + // entry whose first key is `classes:`) from `- ClassName` (a + // class literally named "ClassName" inside the previous + // entry's classes list) — the parser sees the line already + // left-trimmed, so indent can't disambiguate. Restricting to + // known sub-keys makes the order-independence safe in both + // directions: `- namespace: …` first or `- classes: …` first. + if (t.StartsWith("- ", StringComparison.Ordinal)) + { + var rest = t.Substring(2).TrimStart(); + bool isEntryAnchor = + rest.StartsWith("namespace:", StringComparison.OrdinalIgnoreCase) + || rest.StartsWith("classes:", StringComparison.OrdinalIgnoreCase); + if (isEntryAnchor) + { + currentExtra = new JsBindingsExtraType(); + js.ExtraTypes.Add(currentExtra); + inClassesList = false; + // Re-dispatch the rest of the dash line as a child + // key/value by stripping the dash prefix and falling + // through to the sub-key matchers. + t = rest; + } + } + if (currentExtra is null) + { + return; + } + if (t.StartsWith("namespace:", StringComparison.OrdinalIgnoreCase)) + { + currentExtra.Namespace = SanitizeScalar(t.Substring("namespace:".Length)); + inClassesList = false; + return; + } + if (IsTopLevelKey(t, "classes:")) + { + inClassesList = true; + return; + } + // Inline flow-list form: `classes: [X, Y, Z]` or `classes: [X]`. + if (t.StartsWith("classes:", StringComparison.OrdinalIgnoreCase)) + { + var rest = t.Substring("classes:".Length).Trim(); + if (rest.StartsWith('[')) + { + var end = rest.IndexOf(']'); + if (end > 0) + { + var contents = rest.Substring(1, end - 1); + foreach (var item in contents.Split(',')) + { + var name = SanitizeScalar(item); + if (!string.IsNullOrEmpty(name)) + { + currentExtra.Classes.Add(name); + } + } + inClassesList = false; + return; + } + } + // Scalar form: `classes: SingleClass` (no brackets). + if (!string.IsNullOrEmpty(rest)) + { + var name = SanitizeScalar(rest); + if (!string.IsNullOrEmpty(name)) + { + currentExtra.Classes.Add(name); + } + inClassesList = false; + return; + } + } + if (inClassesList && t.StartsWith("- ", StringComparison.Ordinal)) + { + var name = SanitizeScalar(t[2..]); + if (!string.IsNullOrEmpty(name)) + { + currentExtra.Classes.Add(name); + } + return; + } + } + } + + internal static bool TryReadScalar(string t, string prefix, out string value) + { + if (t.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + value = SanitizeScalar(t.Substring(prefix.Length)); + return true; + } + value = string.Empty; + return false; + } + + // Trims surrounding whitespace, strips an unquoted trailing `# comment`, + // then strips a single pair of matching surrounding quotes. Mirrors what + // a YAML parser would do for plain / single- / double-quoted scalars + // (sufficient for the small jsBindings grammar we parse by hand). + // + // `output: bindings/winrt # generated` → `bindings/winrt` + // `name: "weird # name"` → `weird # name` + // `path: 'C:\foo'` → `C:\foo` + internal static string SanitizeScalar(string raw) + { + if (string.IsNullOrEmpty(raw)) + { + return string.Empty; + } + + var trimmed = raw.AsSpan().TrimStart(); + char? quoteOpener = null; + if (trimmed.Length > 0 && (trimmed[0] == '"' || trimmed[0] == '\'')) + { + quoteOpener = trimmed[0]; + } + + int cutoff = trimmed.Length; + // Quote-state tracking only matters when the scalar is actually + // quoted (i.e. `quoteOpener` was set at index 0). For plain + // scalars, an apostrophe inside a value like `John's` must NOT + // make the rest of the line look "inside a single quote" — that + // would suppress the `# comment` boundary and a subsequent save + // would re-quote the value with the comment baked in. + bool trackQuoteState = quoteOpener is not null; + bool inSingle = false; + bool inDouble = false; + for (int i = 0; i < trimmed.Length; i++) + { + var c = trimmed[i]; + if (trackQuoteState) + { + if (inDouble) + { + if (c == '\\' && i + 1 < trimmed.Length) { i++; continue; } + if (c == '"') { inDouble = false; } + continue; + } + if (inSingle) + { + if (c == '\'') { inSingle = false; } + continue; + } + if (c == '"') { inDouble = true; continue; } + if (c == '\'') { inSingle = true; continue; } + } + if (c == '#') + { + // YAML requires whitespace before an unquoted inline comment. + if (i == 0 || char.IsWhiteSpace(trimmed[i - 1])) + { + cutoff = i; + break; + } + } + } + + var value = trimmed.Slice(0, cutoff).TrimEnd(); + // Only peel the OUTER quote pair when it's symmetrical so we don't + // turn `it's` into `it`s`. + if (value.Length >= 2 && quoteOpener is char q && value[0] == q && value[^1] == q) + { + var inner = value.Slice(1, value.Length - 2).ToString(); + if (q == '\'') + { + // YAML single-quoted scalars use `''` as the escape for a + // literal `'`. Render writes `'O''Brien'` for `O'Brien`; + // unescape symmetrically so round-trip is stable. + return inner.Replace("''", "'"); + } + return inner; + } + return value.ToString(); + } + + private static int LeadingSpaceCount(string line) + { + int i = 0; + while (i < line.Length && line[i] == ' ') + { + i++; + } + return i; + } + + // Matches a top-level key like `packages:` or `jsBindings:` with any + // trailing whitespace or inline `# comment`. Used by both Parse and + // SpliceJsBindingsBlock so they never disagree on block presence. + internal static bool IsTopLevelKey(string trimmedLine, string key) + { + if (!trimmedLine.StartsWith(key, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + if (trimmedLine.Length == key.Length) + { + return true; + } + var rest = trimmedLine.AsSpan(key.Length).TrimStart(); + return rest.IsEmpty || rest[0] == '#'; + } + + private enum Section { None, Packages, JsBindings } + private enum JsListMode { None, Packages, ExtraTypes, AdditionalWinmds, AdditionalRefs, SkipPackages, RefOnlyPackages, EmitPackages } + + // Table-driven dispatch for the string-list jsBindings sub-keys (everything + // except ExtraTypes, whose entries are objects). Keeps ParseJsBindingsLine + // honest: adding a new list-mode is one table entry instead of an enum arm + // + a 9-line near-duplicate if-block that could silently drift. + private static readonly Dictionary>> s_listSelectors = new() + { + [JsListMode.Packages] = js => js.Packages, + [JsListMode.AdditionalWinmds] = js => js.AdditionalWinmds, + [JsListMode.AdditionalRefs] = js => js.AdditionalRefs, + [JsListMode.SkipPackages] = js => js.SkipPackages, + [JsListMode.RefOnlyPackages] = js => js.RefOnlyPackages, + [JsListMode.EmitPackages] = js => js.EmitPackages, + }; + + // ------------------------------------------------------------------------- + // Render + // ------------------------------------------------------------------------- + + private static string Stringify(WinappConfig cfg) + { + var sb = new StringBuilder(); + sb.AppendLine("packages:"); + foreach (var p in cfg.Packages) + { + sb.AppendLine($" - name: {QuoteScalar(p.Name)}"); + sb.AppendLine($" version: {QuoteScalar(p.Version)}"); + } + + if (cfg.JsBindings is { } js) + { + sb.AppendLine(); + AppendJsBindingsBlock(sb, js); + } + return sb.ToString(); + } + + // Render the jsBindings: block. Shared by Stringify and SpliceJsBindingsBlock. + private static void AppendJsBindingsBlock(StringBuilder sb, JsBindingsConfig js) + { + sb.AppendLine("jsBindings:"); + sb.AppendLine($" lang: {QuoteScalar(js.Lang)}"); + sb.AppendLine($" output: {QuoteScalar(js.Output)}"); + if (js.Packages.Count > 0) + { + sb.AppendLine(" packages:"); + foreach (var pkg in js.Packages) + { + sb.AppendLine($" - {QuoteScalar(pkg)}"); + } + } + if (js.AdditionalWinmds.Count > 0) + { + sb.AppendLine(" additionalWinmds:"); + foreach (var path in js.AdditionalWinmds) + { + sb.AppendLine($" - {QuoteScalar(path)}"); + } + } + if (js.AdditionalRefs.Count > 0) + { + sb.AppendLine(" additionalRefs:"); + foreach (var path in js.AdditionalRefs) + { + sb.AppendLine($" - {QuoteScalar(path)}"); + } + } + if (js.SkipPackages.Count > 0) + { + sb.AppendLine(" skipPackages:"); + foreach (var pkg in js.SkipPackages) + { + sb.AppendLine($" - {QuoteScalar(pkg)}"); + } + } + if (js.RefOnlyPackages.Count > 0) + { + sb.AppendLine(" refOnlyPackages:"); + foreach (var pkg in js.RefOnlyPackages) + { + sb.AppendLine($" - {QuoteScalar(pkg)}"); + } + } + if (js.EmitPackages.Count > 0) + { + sb.AppendLine(" emitPackages:"); + foreach (var pkg in js.EmitPackages) + { + sb.AppendLine($" - {QuoteScalar(pkg)}"); + } + } + if (js.ExtraTypes.Count > 0) + { + sb.AppendLine(" extraTypes:"); + foreach (var et in js.ExtraTypes) + { + sb.AppendLine($" - namespace: {QuoteScalar(et.Namespace)}"); + if (et.Classes.Count > 0) + { + sb.AppendLine(" classes:"); + foreach (var cls in et.Classes) + { + sb.AppendLine($" - {QuoteScalar(cls)}"); + } + } + } + } + } + + // Quote a YAML scalar with single quotes when the raw value would be + // re-parsed incorrectly by our (or any other) plain-scalar reader. We + // bias toward over-quoting because the cost is cosmetic and the cost + // of UNDER-quoting is silent data corruption on the next load (e.g. + // a Windows path `C:\foo` would otherwise be re-read as a mapping, a + // value containing `#` would be re-read with the comment chopped off). + internal static string QuoteScalar(string value) + { + if (NeedsQuoting(value)) + { + // Single-quoted YAML strings: only `'` needs escaping (doubled). + return "'" + value.Replace("'", "''") + "'"; + } + return value; + } + + private static bool NeedsQuoting(string value) + { + if (string.IsNullOrEmpty(value)) + { + return true; + } + if (char.IsWhiteSpace(value[0]) || char.IsWhiteSpace(value[^1])) + { + return true; + } + // YAML indicators that cannot start a plain scalar (YAML 1.2 §7.3.3). + if ("-?:,[]{}#&*!|>'\"%@`".IndexOf(value[0]) >= 0) + { + return true; + } + // Reserved YAML 1.1 boolean/null literals (parsers may still honor these). + switch (value.ToLowerInvariant()) + { + case "null": + case "~": + case "true": + case "false": + case "yes": + case "no": + case "on": + case "off": + return true; + } + // Numeric-looking values would re-parse as numbers, not strings. + if (long.TryParse(value, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out _)) + { + return true; + } + if (double.TryParse(value, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out _)) + { + return true; + } + foreach (var c in value) + { + if (c == '\t' || c == '\r' || c == '\n') + { + return true; + } + // Any `:` or `#` in the body is enough to change the parse. Windows + // paths (`C:\…`) and values like `note # foo` are the motivating + // cases — be conservative and quote. + if (c == ':' || c == '#') + { + return true; + } + } + return false; + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/WinmdsLockfileService.cs b/src/winapp-CLI/WinApp.Cli/Services/WinmdsLockfileService.cs index 67c26028..4dc496f7 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/WinmdsLockfileService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/WinmdsLockfileService.cs @@ -4,6 +4,7 @@ using System.Text; using System.Text.Json; using Microsoft.Extensions.Logging; +using WinApp.Cli.Helpers; using WinApp.Cli.Models; namespace WinApp.Cli.Services; @@ -16,6 +17,22 @@ internal sealed class WinmdsLockfileService(ILogger logge public FileInfo GetLockfilePath(DirectoryInfo winappDir) => new(Path.Combine(winappDir.FullName, LockfileName)); + // Refuse to read/write the lockfile if `.winapp/` (or any segment of + // its path up to the workspace) is a symlink / junction. The lockfile + // lives next to user-controlled state; a malicious workspace can plant + // `.winapp` as a junction to a UNC share or a victim file before + // winapp ever runs, so we cannot trust the path even though we'd + // normally consider `.winapp/` winapp-managed. + private static bool IsLockfilePathUnsafe(DirectoryInfo winappDir, FileInfo lockfilePath) + { + // Use the parent of `.winapp` (i.e. the workspace) as the boundary + // when discoverable. Fall back to `.winapp` itself otherwise (the + // call still flags the dir being a reparse point because PathSafety + // checks the boundary). + var boundary = winappDir.Parent?.FullName ?? winappDir.FullName; + return PathSafety.HasReparsePointOnPath(lockfilePath.FullName, boundary); + } + public async Task WriteAsync( DirectoryInfo winappDir, IReadOnlyDictionary usedVersions, @@ -27,9 +44,20 @@ public async Task WriteAsync( string? tempPath = null; try { + var path = GetLockfilePath(winappDir); + if (IsLockfilePathUnsafe(winappDir, path)) + { + // Lockfile is an optimization, not a correctness requirement — + // log + skip rather than throw, so codegen still proceeds via + // live discovery. + logger.LogDebug( + "Skipping winmds lockfile write at {LockfilePath}: .winapp or one of its ancestors is a symlink / reparse point.", + path.FullName); + return; + } + winappDir.Create(); var lockfile = BuildLockfile(usedVersions, discoveredWinmds, nugetCacheDir, yamlPackagesHash); - var path = GetLockfilePath(winappDir); // Atomic write via tmp + rename; guid suffix avoids concurrent // writers colliding on staging. @@ -68,6 +96,14 @@ await File.WriteAllTextAsync( CancellationToken cancellationToken = default) { var path = GetLockfilePath(winappDir); + if (IsLockfilePathUnsafe(winappDir, path)) + { + logger.LogDebug( + "Skipping winmds lockfile read at {LockfilePath}: .winapp or one of its ancestors is a symlink / reparse point.", + path.FullName); + return null; + } + if (!path.Exists) { return null; diff --git a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Init.cs b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Init.cs new file mode 100644 index 00000000..5ea8cefb --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Init.cs @@ -0,0 +1,268 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using Spectre.Console; +using WinApp.Cli.Helpers; +using WinApp.Cli.Models; + +namespace WinApp.Cli.Services; + +// WorkspaceSetupService — config-init slice. +// +// Owns the logic that decides whether we're "init" or "restore", loads or +// scaffolds winapp.yaml, walks the user through SDK / manifest / dev-mode +// prompts on first run, validates a .NET project's TargetFramework, and +// emits the default jsBindings: block when `--js-bindings` is supplied. +// Result tuple is consumed by SetupWorkspaceAsync to decide the rest of +// the flow. +internal partial class WorkspaceSetupService +{ + private async Task<(int ReturnCode, WinappConfig? Config, bool HadExistingConfig, bool ShouldGenerateManifest, ManifestGenerationInfo? ManifestGenerationInfo, bool ShouldEnableDeveloperMode, string? RecommendedTfm)> InitializeConfigurationAsync(WorkspaceSetupOptions options, bool isDotNetProject, FileInfo? csprojFile, CancellationToken cancellationToken) + { + if (!options.RequireExistingConfig && !options.ConfigOnly && options.SdkInstallMode == null && options.UseDefaults) + { + // Default to Stable when --use-defaults + options.SdkInstallMode = SdkInstallMode.Stable; + } + + var hadExistingConfig = configService.Exists(); + bool shouldGenerateManifest = true; + bool shouldEnableDeveloperMode = false; + string? recommendedTfm = null; + ManifestGenerationInfo? manifestGenerationInfo = null; + WinappConfig? config = null; + + // Step 1: Handle configuration requirements + if (options.RequireExistingConfig && !configService.Exists()) + { + // Non-.NET project with no winapp.yaml — nothing to restore. + // (.NET projects without yaml are handled earlier in SetupWorkspaceAsync.) + // This is a no-op rather than an error: a project that doesn't declare + // SDK package versions in winapp.yaml has nothing for restore to do. + logger.LogInformation("{UISymbol} No winapp.yaml found in {ConfigDir}. Nothing to restore.", UiSymbols.Note, options.ConfigDir); + logger.LogInformation("If this project needs Windows SDK packages, run 'winapp init' to set them up."); + return (0, config, hadExistingConfig, shouldGenerateManifest, manifestGenerationInfo, shouldEnableDeveloperMode, recommendedTfm); + } + + // Step 2: Load or prepare configuration + if (hadExistingConfig) + { + config = configService.Load(); + + if (config.Packages.Count == 0 && options.RequireExistingConfig) + { + logger.LogInformation("{UISymbol} winapp.yaml found but contains no packages. Nothing to restore.", UiSymbols.Note); + shouldEnableDeveloperMode = await AskShouldEnableDeveloperModeAsync(options, cancellationToken); + return (0, config, hadExistingConfig, shouldGenerateManifest, manifestGenerationInfo, shouldEnableDeveloperMode, recommendedTfm); + } + + var operation = options.RequireExistingConfig ? "Found" : "Found existing"; + logger.LogDebug("{UISymbol} {Operation} winapp.yaml with {PackageCount} packages", UiSymbols.Package, operation, config.Packages.Count); + + // Re-init hint: surface JS bindings capability for npm-shim users + // who haven't opted in (winget users can't use --js-bindings). + if (!options.RequireExistingConfig + && !options.AddJsBindings + && config.JsBindings is null + && string.Equals( + Environment.GetEnvironmentVariable("WINAPP_CLI_CALLER"), + "nodejs-package", + StringComparison.Ordinal)) + { + logger.LogInformation( + "{UISymbol} To add JS/TS bindings to this project, re-run: npx winapp init --js-bindings", + UiSymbols.Info); + } + + if (!options.RequireExistingConfig && config.Packages.Count > 0) + { + logger.LogDebug("{UISymbol} Using pinned package versions from winapp.yaml unless overridden.", UiSymbols.Note); + } + + // For setup command: ask about overwriting existing config (only if not skipping SDK installation and not config-only mode) + if (!options.RequireExistingConfig && !options.IgnoreConfig && !options.ConfigOnly && options.SdkInstallMode != SdkInstallMode.None && config.Packages.Count > 0) + { + if (options.UseDefaults) + { + options.IgnoreConfig = true; + } + else + { + var overwriteConfig = await ShowConfirmationPromptAsync(ansiConsole, "winapp.yaml exists with pinned versions. Overwrite?", cancellationToken); + shouldGenerateManifest = await AskShouldGenerateManifestAsync(options, cancellationToken); + if (shouldGenerateManifest) + { + manifestGenerationInfo = await PromptForManifestInfoAsync(options, cancellationToken); + } + if (!overwriteConfig) + { + options.IgnoreConfig = true; + } + else + { + await AskSdkInstallModeAsync(options, isDotNetProject, csprojFile, cancellationToken); + } + } + } + } + else + { + shouldGenerateManifest = await AskShouldGenerateManifestAsync(options, cancellationToken); + if (shouldGenerateManifest) + { + manifestGenerationInfo = await PromptForManifestInfoAsync(options, cancellationToken); + } + + await AskSdkInstallModeAsync(options, isDotNetProject, csprojFile, cancellationToken); + if (options.SdkInstallMode != SdkInstallMode.None) + { + config = new WinappConfig(); + logger.LogDebug("{UISymbol} No winapp.yaml found; will generate one after setup.", UiSymbols.New); + } + } + + // Re-check after AskSdkInstallModeAsync: the interactive prompt + // can leave SdkInstallMode=None, which breaks --js-bindings. + if (options.AddJsBindings && options.SdkInstallMode == SdkInstallMode.None) + { + logger.LogError( + "{UISymbol} --js-bindings requires SDK packages but the SDK install mode was set to 'none'. " + + "Re-run without --js-bindings, or pick a non-'none' SDK mode (stable / preview / experimental).", + UiSymbols.Error); + return (1, config, hadExistingConfig, shouldGenerateManifest, manifestGenerationInfo, shouldEnableDeveloperMode, recommendedTfm); + } + + // --js-bindings: fill a default block when none exists; never overwrite. + if (options.AddJsBindings && config != null && config.JsBindings is not null) + { + // Warn when override flags are ignored because a block already exists. + var hasOverrides = !string.IsNullOrWhiteSpace(options.JsBindingsOutputOverride) + || !string.IsNullOrWhiteSpace(options.JsBindingsLangOverride) + || (options.JsBindingsPresets is { Count: > 0 }); + if (hasOverrides) + { + logger.LogWarning( + "{UISymbol} --js-bindings-output / --js-bindings-lang / --js-bindings-{{preset}} are " + + "ignored because winapp.yaml already declares a jsBindings block. " + + "Use 'npx winapp node jsbindings add --force' to overwrite specific fields.", + UiSymbols.Warning); + } + } + if (options.AddJsBindings && config != null && config.JsBindings is null) + { + var jsCfg = new JsBindingsConfig(); + if (!string.IsNullOrWhiteSpace(options.JsBindingsOutputOverride)) + { + jsCfg.Output = options.JsBindingsOutputOverride!.Trim(); + } + if (!string.IsNullOrWhiteSpace(options.JsBindingsLangOverride)) + { + jsCfg.Lang = options.JsBindingsLangOverride!.Trim(); + } + + // Validate the resolved output path before persisting. + try + { + DynWinrtCodegenService.ResolveOutputDir(options.BaseDirectory, jsCfg.Output); + } + catch (InvalidOperationException ex) + { + logger.LogError( + "{UISymbol} Invalid --js-bindings-output: {Reason}", + UiSymbols.Error, ex.Message); + return (1, config, hadExistingConfig, shouldGenerateManifest, manifestGenerationInfo, shouldEnableDeveloperMode, recommendedTfm); + } + if (options.JsBindingsPresets is { Count: > 0 } presetNames) + { + var packageIds = JsBindingsPresets.ResolveAndUnion(presetNames); + if (packageIds.Count > 0) + { + jsCfg.Packages = new List(packageIds); + logger.LogDebug( + "{UISymbol} jsBindings presets [{Presets}] → packages=[{Packages}]", + UiSymbols.New, + string.Join(", ", presetNames), + string.Join(", ", packageIds)); + } + else + { + // Defensive: InitCommand validates preset names upstream. + logger.LogWarning( + "{UISymbol} jsBindings presets [{Presets}] resolved to no prefixes; ignoring (known: {Known}).", + UiSymbols.Warning, + string.Join(", ", presetNames), + JsBindingsPresets.KnownPresetsDisplay()); + } + } + config.JsBindings = jsCfg; + logger.LogDebug( + "{UISymbol} --js-bindings: added default jsBindings block (lang={Lang}, output={Output})", + UiSymbols.New, + config.JsBindings.Lang, + config.JsBindings.Output); + + // Note: @microsoft/dynwinrt is added as a production dep AFTER + // bindings succeed (JsBindingsWorkspaceService.RunAsync). Doing + // it here would leave package.json mutated if codegen failed. + } + + // .NET: Validate TargetFramework (interactive) + if (isDotNetProject && csprojFile != null) + { + if (dotNetService.IsMultiTargeted(csprojFile)) + { + logger.LogError("The project '{CsprojFile}' uses multi-targeting (TargetFrameworks). winapp init does not support multi-targeted projects.", csprojFile.Name); + return (1, config, hadExistingConfig, shouldGenerateManifest, manifestGenerationInfo, shouldEnableDeveloperMode, recommendedTfm); + } + + var currentTfm = dotNetService.GetTargetFramework(csprojFile); + logger.LogDebug("Current TargetFramework: {Tfm}", currentTfm ?? "(not set)"); + + if (currentTfm == null || !dotNetService.IsTargetFrameworkSupported(currentTfm)) + { + recommendedTfm = dotNetService.GetRecommendedTargetFramework(currentTfm); + + if (!options.UseDefaults) + { + var currentDisplay = currentTfm ?? "(not set)"; + + var promptSuffix = options.SdkInstallMode != SdkInstallMode.None + ? " (Required for Windows App SDK)" + : ""; + + var shouldUpdate = await ShowConfirmationPromptAsync(ansiConsole, $"Update TargetFramework to \"{recommendedTfm}\"{promptSuffix}?", cancellationToken); + + if (!shouldUpdate) + { + if (options.SdkInstallMode != SdkInstallMode.None) + { + logger.LogError("TargetFramework '{Tfm}' is not supported for Windows App SDK. Cannot continue.", currentDisplay); + return (1, config, hadExistingConfig, shouldGenerateManifest, manifestGenerationInfo, shouldEnableDeveloperMode, recommendedTfm); + } + + // Not installing SDKs, so TFM update is not required — skip it + recommendedTfm = null; + } + } + else + { + var currentDisplay = currentTfm ?? "(not set)"; + logger.LogWarning( + "TargetFramework '{CurrentTfm}' is not supported for Windows App SDK. Automatically updating to '{RecommendedTfm}' because --use-defaults was specified.", + currentDisplay, + recommendedTfm); + logger.LogInformation("Automatically updating TargetFramework from {CurrentTfm} to {RecommendedTfm} because --use-defaults was specified.", Markup.Escape(currentDisplay), recommendedTfm); + } + } + else + { + logger.LogDebug("{UISymbol} TargetFramework '{Tfm}' is supported", UiSymbols.Check, currentTfm); + } + } + + shouldEnableDeveloperMode = await AskShouldEnableDeveloperModeAsync(options, cancellationToken); + + return (0, config, hadExistingConfig, shouldGenerateManifest, manifestGenerationInfo, shouldEnableDeveloperMode, recommendedTfm); + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Msix.cs b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Msix.cs new file mode 100644 index 00000000..2534f5ff --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Msix.cs @@ -0,0 +1,298 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Runtime.InteropServices; +using WinApp.Cli.ConsoleTasks; +using WinApp.Cli.Helpers; + +namespace WinApp.Cli.Services; + +// WorkspaceSetupService — MSIX runtime install slice. +// +// Reads the per-architecture MSIX inventory from the NuGet cache, resolves +// each package's real identity from its AppxManifest.xml, and installs +// Windows App SDK runtime packages (skipping already-installed-equal-or-newer +// ones). Also owns NuGet-cache MSIX directory lookup. +internal partial class WorkspaceSetupService +{ + // Package entry information from MSIX inventory + public class MsixPackageEntry + { + public required string FileName { get; set; } + public required string PackageIdentity { get; set; } + } + + // Parses the MSIX inventory file and returns package entries (shared implementation) + public static async Task?> ParseMsixInventoryAsync(TaskContext taskContext, DirectoryInfo msixDir, CancellationToken cancellationToken) + { + var architecture = GetSystemArchitecture(); + + taskContext.AddDebugMessage($"{UiSymbols.Note} Detected system architecture: {architecture}"); + + // Look for MSIX packages for the current architecture + var msixArchDir = Path.Combine(msixDir.FullName, $"win10-{architecture}"); + if (!Directory.Exists(msixArchDir)) + { + taskContext.AddDebugMessage($"{UiSymbols.Note} No MSIX packages found for architecture {architecture}"); + taskContext.AddDebugMessage($"{UiSymbols.Note} Available directories: {string.Join(", ", msixDir.GetDirectories().Select(d => d.Name))}"); + return null; + } + + // Read the MSIX inventory file + var inventoryPath = Path.Combine(msixArchDir, "msix.inventory"); + if (!File.Exists(inventoryPath)) + { + taskContext.AddDebugMessage($"{UiSymbols.Note} No msix.inventory file found in {msixArchDir}"); + return null; + } + + var inventoryLines = await File.ReadAllLinesAsync(inventoryPath, cancellationToken); + var packageEntries = inventoryLines + .Where(line => !string.IsNullOrWhiteSpace(line) && line.Contains('=')) + .Select(line => line.Split('=', 2)) + .Where(parts => parts.Length == 2) + .Select(parts => new MsixPackageEntry { FileName = parts[0].Trim(), PackageIdentity = parts[1].Trim() }) + .ToList(); + + if (packageEntries.Count == 0) + { + taskContext.AddDebugMessage($"{UiSymbols.Note} No valid package entries found in msix.inventory"); + return null; + } + + taskContext.AddDebugMessage($"{UiSymbols.Package} Found {packageEntries.Count} MSIX packages in inventory"); + + return packageEntries; + } + + // Reads the actual package Name and Version from the AppxManifest.xml inside an MSIX file. + // The MSIX inventory file can have incorrect package names (e.g., the DDLM), so we read + // the real identity directly from the package to ensure correct installation checks. + private static (string? Name, string? Version) ReadMsixIdentity(string msixFilePath, TaskContext taskContext) + { + try + { + using var zip = System.IO.Compression.ZipFile.OpenRead(msixFilePath); + var manifestEntry = zip.GetEntry("AppxManifest.xml"); + if (manifestEntry == null) + { + return (null, null); + } + + using var stream = manifestEntry.Open(); + var manifest = AppxManifestDocument.Load(stream); + return (manifest.IdentityName, manifest.IdentityVersion); + } + catch (Exception ex) + { + taskContext.AddDebugMessage($"{UiSymbols.Note} Could not read identity from {Path.GetFileName(msixFilePath)}: {ex.Message}"); + return (null, null); + } + } + + // Installs Windows App SDK runtime MSIX packages for the current system architecture + public async Task<(int InstalledCount, int ErrorCount)> InstallWindowsAppRuntimeAsync(DirectoryInfo msixDir, TaskContext taskContext, CancellationToken cancellationToken) + { + var architecture = GetSystemArchitecture(); + + // Get package entries from MSIX inventory + var packageEntries = await ParseMsixInventoryAsync(taskContext, msixDir, cancellationToken); + if (packageEntries == null || packageEntries.Count == 0) + { + return (0, 0); + } + + var msixArchDir = Path.Combine(msixDir.FullName, $"win10-{architecture}"); + + // Build list of packages to evaluate + var packagesToCheck = new List<(string FilePath, string PackageName, string NewVersion, string FileName)>(); + foreach (var entry in packageEntries) + { + var msixFilePath = Path.Combine(msixArchDir, entry.FileName); + if (!File.Exists(msixFilePath)) + { + taskContext.AddDebugMessage($"{UiSymbols.Note} MSIX file not found: {msixFilePath}"); + continue; + } + + // Read the actual package identity from the MSIX's AppxManifest.xml. + // The inventory file's PackageIdentity can differ from the real installed name. + var (packageName, newVersionString) = ReadMsixIdentity(msixFilePath, taskContext); + if (packageName == null) + { + // Fallback: parse from inventory identity string + var identityParts = entry.PackageIdentity.Split('_'); + packageName = identityParts[0]; + newVersionString = identityParts.Length >= 2 ? identityParts[1] : ""; + } + + packagesToCheck.Add((msixFilePath, packageName, newVersionString ?? "", entry.FileName)); + } + + if (packagesToCheck.Count == 0) + { + return (0, 0); + } + + taskContext.AddDebugMessage($"{UiSymbols.Info} Checking and installing {packagesToCheck.Count} MSIX packages"); + + var installedCount = 0; + var errorCount = 0; + + foreach (var (filePath, packageName, newVersion, fileName) in packagesToCheck) + { + // Check if already installed with same or newer version + var installedVersion = packageRegistrationService.GetInstalledVersion(packageName); + if (installedVersion != null) + { + if (Version.TryParse(installedVersion, out var existing) && + Version.TryParse(newVersion, out var incoming) && + existing >= incoming) + { + taskContext.AddDebugMessage($"{UiSymbols.Check} {fileName}: Already installed or newer version exists"); + continue; + } + } + + taskContext.AddDebugMessage($"{UiSymbols.Info} {fileName}: Will install"); + + try + { + await packageRegistrationService.InstallPackageAsync(filePath, cancellationToken); + installedCount++; + taskContext.AddDebugMessage($"{UiSymbols.Check} {fileName}: Installation successful"); + } + catch (Exception ex) + { + errorCount++; + taskContext.AddDebugMessage($"{UiSymbols.Note} {fileName}: {ex.Message}"); + } + } + + // Provide summary feedback + if (installedCount > 0) + { + taskContext.AddDebugMessage($"{UiSymbols.Check} Installed {installedCount} MSIX packages"); + } + if (errorCount > 0) + { + taskContext.AddDebugMessage($"{UiSymbols.Note} {errorCount} packages failed to install"); + } + + return (installedCount, errorCount); + } + + // Gets the current system architecture string for package selection + public static string GetSystemArchitecture() + { + var arch = RuntimeInformation.ProcessArchitecture; + return arch switch + { + Architecture.X64 => "x64", + Architecture.Arm64 => "arm64", + Architecture.X86 => "x86", + _ => "x64" // Default fallback + }; + } + + // Finds the MSIX directory for Windows App SDK runtime packages + public DirectoryInfo? FindWindowsAppSdkMsixDirectory(Dictionary? usedVersions = null) + { + var nugetCacheDir = nugetService.GetNuGetGlobalPackagesDir(); + return FindMsixDirectoryInNuGetCache(nugetCacheDir, usedVersions); + } + + // Searches the NuGet global packages cache (lowercase id/version folder convention). + private DirectoryInfo? FindMsixDirectoryInNuGetCache(DirectoryInfo nugetCacheDir, Dictionary? usedVersions) + { + if (usedVersions != null) + { + // Try runtime package first (Windows App SDK 1.8+) + if (usedVersions.TryGetValue(BuildToolsService.WINAPP_SDK_RUNTIME_PACKAGE, out var runtimeVersion)) + { + var msixDir = TryGetMsixDirectoryFromNuGetCache(BuildToolsService.WINAPP_SDK_RUNTIME_PACKAGE, runtimeVersion); + if (msixDir != null) + { + return msixDir; + } + } + + // Fallback to main package + if (usedVersions.TryGetValue(BuildToolsService.WINAPP_SDK_PACKAGE, out var mainVersion)) + { + var msixDir = TryGetMsixDirectoryFromNuGetCache(BuildToolsService.WINAPP_SDK_PACKAGE, mainVersion); + if (msixDir != null) + { + return msixDir; + } + } + } + + // General scan: look for any runtime package directories + var runtimeDir = new DirectoryInfo(Path.Combine(nugetCacheDir.FullName, BuildToolsService.WINAPP_SDK_RUNTIME_PACKAGE.ToLowerInvariant())); + if (runtimeDir.Exists) + { + foreach (var versionDir in runtimeDir.GetDirectories().OrderByDescending(d => d.Name, new VersionStringComparer())) + { + var msixDir = TryGetMsixDirectoryFromPath(versionDir); + if (msixDir != null) + { + return msixDir; + } + } + } + + // Fallback: main package + var mainDir = new DirectoryInfo(Path.Combine(nugetCacheDir.FullName, BuildToolsService.WINAPP_SDK_PACKAGE.ToLowerInvariant())); + if (mainDir.Exists) + { + foreach (var versionDir in mainDir.GetDirectories().OrderByDescending(d => d.Name, new VersionStringComparer())) + { + var msixDir = TryGetMsixDirectoryFromPath(versionDir); + if (msixDir != null) + { + return msixDir; + } + } + } + + return null; + } + + // Checks the NuGet cache for a specific package/version. + private DirectoryInfo? TryGetMsixDirectoryFromNuGetCache(string packageId, string version) + { + var pkgVersionDir = nugetService.GetNuGetPackageDir(packageId, version); + return TryGetMsixDirectoryFromPath(pkgVersionDir); + } + + // Helper method to check if an MSIX directory exists for a given package path + private static DirectoryInfo? TryGetMsixDirectoryFromPath(DirectoryInfo packagePath) + { + var msixDir = new DirectoryInfo(Path.Combine(packagePath.FullName, "tools", "MSIX")); + return msixDir.Exists ? msixDir : null; + } + + // Comparer for sorting version strings, including prerelease support + private class VersionStringComparer : IComparer + { + public int Compare(string? x, string? y) + { + if (x == null && y == null) + { + return 0; + } + if (x == null) + { + return -1; + } + if (y == null) + { + return 1; + } + + // Use the same comparison logic as NugetService.CompareVersions + return NugetService.CompareVersions(x, y); + } + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Options.cs b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Options.cs new file mode 100644 index 00000000..1644361d --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Options.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.Models; + +namespace WinApp.Cli.Services; + +// Parameters for workspace setup operations +internal class WorkspaceSetupOptions +{ + public required DirectoryInfo BaseDirectory { get; set; } + public required DirectoryInfo ConfigDir { get; set; } + public SdkInstallMode? SdkInstallMode { get; set; } + public bool IgnoreConfig { get; set; } + public bool NoGitignore { get; set; } + public bool UseDefaults { get; set; } + public bool RequireExistingConfig { get; set; } + public bool ForceLatestBuildTools { get; set; } + public bool ConfigOnly { get; set; } + + // Enable JS/TS bindings generation in Step 5.5 of setup. + public bool AddJsBindings { get; set; } + + // CLI override for jsBindings.output. + public string? JsBindingsOutputOverride { get; set; } + + // CLI override for jsBindings.lang. + public string? JsBindingsLangOverride { get; set; } + + // Preset names from JsBindingsPresets — unioned into jsBindings.packages. + public IReadOnlyList? JsBindingsPresets { get; set; } +} + +// Params for AddJsBindingsAsync. +internal class AddJsBindingsOptions +{ + public required DirectoryInfo BaseDirectory { get; set; } + public required DirectoryInfo ConfigDir { get; set; } + + // CLI override for jsBindings.output. + public string? Output { get; set; } + + // Preset names from JsBindingsPresets. + public IReadOnlyList? Presets { get; set; } + + // Patch an existing jsBindings: block without prompting. + public bool Force { get; set; } + + // Preserve an existing jsBindings: block and exit 0 without prompting. + // Mutually exclusive with Force. + public bool UseDefaults { get; set; } +} + +// Params for the read-only `node jsbindings generate` flow. +internal class GenerateJsBindingsOptions +{ + public required DirectoryInfo BaseDirectory { get; set; } + public required DirectoryInfo ConfigDir { get; set; } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Prompts.cs b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Prompts.cs new file mode 100644 index 00000000..d72499c5 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Prompts.cs @@ -0,0 +1,240 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using Spectre.Console; +using WinApp.Cli.Helpers; +using WinApp.Cli.Models; + +namespace WinApp.Cli.Services; + +// WorkspaceSetupService — Spectre.Console prompt slice. +// +// Holds every interactive prompt the init/restore flow needs (manifest +// generation, developer-mode toggle, SDK install mode, .csproj picker, +// confirmation). Splitting these out keeps the orchestration file focused +// on control flow and lets us evolve UI without churning the main file. +internal partial class WorkspaceSetupService +{ + // Selects the .csproj file to configure when multiple are found. + private async Task SelectCsprojFileAsync(IReadOnlyList csprojFiles, CancellationToken cancellationToken) + { + if (csprojFiles.Count == 1) + { + return csprojFiles[0]; + } + + // Multiple .csproj files found — ask the user which one to use + var choices = csprojFiles.Select(f => f.Name).ToArray(); + var selected = await ansiConsole.PromptAsync( + new SelectionPrompt() + .Title("Multiple .csproj files found. Which project should be configured?") + .AddChoices(choices), + cancellationToken); + return csprojFiles.First(f => f.Name == selected); + } + + private static async Task ShowConfirmationPromptAsync(IAnsiConsole ansiConsole, string prompt, CancellationToken cancellationToken) + { + var result = await ansiConsole.PromptAsync(new ConfirmationPrompt(prompt), cancellationToken); + + ansiConsole.Cursor.MoveUp(); + ansiConsole.Write("\x1b[2K"); // Clear line + ansiConsole.MarkupLine($"{prompt}: [underline]{(result ? "Yes" : "No")}[/]"); + + return result; + } + + private async Task PromptForManifestInfoAsync(WorkspaceSetupOptions options, CancellationToken cancellationToken) + { + if (options.ConfigOnly) + { + return null; + } + + return await manifestService.PromptForManifestInfoAsync(options.BaseDirectory, null, null, "1.0.0.0", "Windows Application", null, options.UseDefaults, cancellationToken); + } + + private async Task AskShouldEnableDeveloperModeAsync(WorkspaceSetupOptions options, CancellationToken cancellationToken) + { + if (options.ConfigOnly || options.RequireExistingConfig) + { + return false; + } + + if (devModeService.IsEnabled()) + { + return false; + } + + if (options.UseDefaults) + { + return false; + } + + return await ShowConfirmationPromptAsync(ansiConsole, "Enable Developer Mode (requires elevation and you will be prompted by User Account Control)", cancellationToken); + } + + private async Task AskShouldGenerateManifestAsync(WorkspaceSetupOptions options, CancellationToken cancellationToken) + { + if (options.RequireExistingConfig) + { + return true; + } + + // Check if manifest already exists, and if so, ask about overwriting + var manifestPath = MsixService.FindProjectManifest(currentDirectoryProvider, options.BaseDirectory); + if ((manifestPath?.Exists) == true) + { + logger.LogDebug("{UISymbol} {ManifestFileName} already exists at {ManifestPath}", UiSymbols.Check, manifestPath.Name, manifestPath.FullName); + if (options.UseDefaults) + { + // With --use-defaults, skip overwriting existing manifest (non-destructive) + return false; + } + else + { + return await ShowConfirmationPromptAsync(ansiConsole, $"{manifestPath.Name} already exists. Overwrite?", cancellationToken); + } + } + + return true; + } + + private async Task AskSdkInstallModeAsync(WorkspaceSetupOptions options, bool isDotNetProject, FileInfo? csprojFile, CancellationToken cancellationToken) + { + // For init (not restore), prompt for SDK installation choice if not specified + if (!options.RequireExistingConfig && !options.ConfigOnly && options.SdkInstallMode == null) + { + // If the .NET project already references WinAppSDK, skip the prompt and default to None. + // This call may take a while on a fresh machine because `dotnet list package` triggers + // an implicit restore — surface a spinner so the user knows we're doing something (#463). + if (isDotNetProject && csprojFile != null) + { + var alreadyReferencesWinAppSdk = await RunWithStatusAsync( + "Detecting project SDK references...", + ct => dotNetService.HasPackageReferenceAsync(csprojFile, DotNetService.WINAPP_SDK_NUGET_PACKAGE, ct), + cancellationToken); + if (alreadyReferencesWinAppSdk) + { + options.SdkInstallMode = SdkInstallMode.None; + logger.LogInformation("{UISymbol} Project already references {PackageName}; skipping Windows App SDK setup.", UiSymbols.Check, DotNetService.WINAPP_SDK_NUGET_PACKAGE); + return; + } + } + // Determine which packages to show versions for + var packages = isDotNetProject + ? [BuildToolsService.WINAPP_SDK_PACKAGE] + : new[] { BuildToolsService.CPP_SDK_PACKAGE, BuildToolsService.WINAPP_SDK_PACKAGE }; + + // Fetch versions for all modes in parallel (failures are non-fatal). On a fresh machine + // these NuGet feed calls can take many seconds; show a spinner so the prompt doesn't + // appear to hang (#463). + var modes = new[] { SdkInstallMode.Stable, SdkInstallMode.Preview, SdkInstallMode.Experimental }; + var versionTasks = await RunWithStatusAsync( + "Fetching latest SDK versions...", + async ct => + { + var tasks = modes + .SelectMany(mode => packages.Select(pkg => (Mode: mode, Package: pkg, Task: SafeGetLatestVersionAsync(pkg, mode, ct)))) + .ToList(); + await Task.WhenAll(tasks.Select(v => v.Task)); + return tasks; + }, + cancellationToken); + + // Build a lookup: (mode) → version label + var versionsByMode = modes.ToDictionary( + mode => mode, + mode => + { + var parts = versionTasks + .Where(v => v.Mode == mode && v.Task.Result != null) + .Select(v => $"{(v.Package == BuildToolsService.CPP_SDK_PACKAGE ? "Windows SDK" : "Windows App SDK")} [green]{v.Task.Result}[/]"); + return string.Join(", ", parts); + }); + + var label = isDotNetProject ? "Windows App SDK" : "SDKs"; + string FormatChoice(string modeLabel, SdkInstallMode mode) + { + var versions = versionsByMode[mode]; + return string.IsNullOrEmpty(versions) + ? $"Setup {modeLabel} {label}" + : $"Setup {modeLabel} {label} ({versions})"; + } + string[] sdkChoices = [ + FormatChoice("Stable", SdkInstallMode.Stable), + FormatChoice("Preview", SdkInstallMode.Preview), + FormatChoice("Experimental", SdkInstallMode.Experimental), + $"Do not setup {label}" + ]; + + ansiConsole.WriteLine($"Select {label} setup option:"); + var sdkPrompt = new SelectionPrompt() + .AddChoices(sdkChoices); + + var sdkChoice = await ansiConsole.PromptAsync(sdkPrompt, cancellationToken); + + ansiConsole.Cursor.MoveUp(); + ansiConsole.Write("\x1b[2K"); // Clear line + + if (sdkChoice == sdkChoices[0]) + { + options.SdkInstallMode = SdkInstallMode.Stable; + } + else if (sdkChoice == sdkChoices[1]) + { + options.SdkInstallMode = SdkInstallMode.Preview; + } + else if (sdkChoice == sdkChoices[2]) + { + options.SdkInstallMode = SdkInstallMode.Experimental; + } + else + { + options.SdkInstallMode = SdkInstallMode.None; + logger.LogInformation("Setup {Label}: Do not setup {Label}", label, label); + return; + } + + ansiConsole.MarkupLine($"Setup {label}: [underline]{Markup.Remove(sdkChoice["Setup ".Length..])}[/]"); + } + } + + private async Task SafeGetLatestVersionAsync(string packageName, SdkInstallMode mode, CancellationToken cancellationToken) + { + try + { + return await nugetService.GetLatestVersionAsync(packageName, sdkInstallMode: mode, cancellationToken: cancellationToken); + } + catch (Exception ex) + { + logger.LogDebug("Failed to fetch latest version for {PackageName} ({Mode}): {ErrorMessage}", packageName, mode, ex.Message); + return null; + } + } + + // Runs work while showing a Spectre.Console spinner with message. + // In non-interactive contexts (redirected output, no Information logging), + // falls back to a single log line so the user still sees what's happening (#463). + private async Task RunWithStatusAsync(string message, Func> work, CancellationToken cancellationToken) + { + if (Environment.UserInteractive + && !Console.IsOutputRedirected + && logger.IsEnabled(LogLevel.Information) + && ansiConsole.Profile.Capabilities.Interactive) + { + T result = default!; + await ansiConsole.Status() + .Spinner(Spinner.Known.Dots) + .StartAsync(message, async _ => + { + result = await work(cancellationToken); + }); + return result; + } + + logger.LogInformation("{Message}", message); + return await work(cancellationToken); + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.cs b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.cs index 1444615a..b8b891c3 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.cs @@ -10,61 +10,12 @@ namespace WinApp.Cli.Services; -// Parameters for workspace setup operations -internal class WorkspaceSetupOptions -{ - public required DirectoryInfo BaseDirectory { get; set; } - public required DirectoryInfo ConfigDir { get; set; } - public SdkInstallMode? SdkInstallMode { get; set; } - public bool IgnoreConfig { get; set; } - public bool NoGitignore { get; set; } - public bool UseDefaults { get; set; } - public bool RequireExistingConfig { get; set; } - public bool ForceLatestBuildTools { get; set; } - public bool ConfigOnly { get; set; } - - // Enable JS/TS bindings generation in Step 5.5 of setup. - public bool AddJsBindings { get; set; } - - // CLI override for jsBindings.output. - public string? JsBindingsOutputOverride { get; set; } - - // CLI override for jsBindings.lang. - public string? JsBindingsLangOverride { get; set; } - - // Preset names from JsBindingsPresets — unioned into jsBindings.packages. - public IReadOnlyList? JsBindingsPresets { get; set; } -} - -// Params for AddJsBindingsAsync. -internal class AddJsBindingsOptions -{ - public required DirectoryInfo BaseDirectory { get; set; } - public required DirectoryInfo ConfigDir { get; set; } - - // CLI override for jsBindings.output. - public string? Output { get; set; } - - // Preset names from JsBindingsPresets. - public IReadOnlyList? Presets { get; set; } - - // Patch an existing jsBindings: block without prompting. - public bool Force { get; set; } - - // Preserve an existing jsBindings: block and exit 0 without prompting. - // Mutually exclusive with Force. - public bool UseDefaults { get; set; } -} - -// Params for the read-only `node jsbindings generate` flow. -internal class GenerateJsBindingsOptions -{ - public required DirectoryInfo BaseDirectory { get; set; } - public required DirectoryInfo ConfigDir { get; set; } -} - -// Shared service for setting up winapp workspaces -internal class WorkspaceSetupService( +// Shared service for setting up winapp workspaces. Split into partials: +// - this file: orchestration (SetupWorkspaceAsync, init/restore flow, JS bindings step glue) +// - WorkspaceSetupService.Options.cs: option DTOs (WorkspaceSetupOptions, AddJsBindingsOptions, GenerateJsBindingsOptions) +// - WorkspaceSetupService.Prompts.cs: Spectre.Console prompts (SDK choice, manifest, dev mode, .csproj picker) +// - WorkspaceSetupService.Msix.cs: Windows App SDK runtime MSIX install / NuGet-cache discovery +internal partial class WorkspaceSetupService( IConfigService configService, IWinappDirectoryService winappDirectoryService, IPackageInstallationService packageInstallationService, @@ -89,8 +40,7 @@ public async Task SetupWorkspaceAsync(WorkspaceSetupOptions options, Cancel { configService.ConfigPath = new FileInfo(Path.Combine(options.ConfigDir.FullName, "winapp.yaml")); - // --js-bindings needs installed SDK packages; --setup-sdks none would - // produce a silent no-op. + // --js-bindings needs installed SDK packages; reject --setup-sdks none. if (options.AddJsBindings && options.SdkInstallMode == SdkInstallMode.None) { logger.LogError( @@ -123,9 +73,7 @@ public async Task SetupWorkspaceAsync(WorkspaceSetupOptions options, Cancel return 1; } - // --js-bindings is unsupported on .NET projects — the local .winapp/ - // workspace isn't initialized for them, so codegen would silently - // skip downstream. Reject up-front with an actionable error. + // --js-bindings is unsupported on .NET projects (no .winapp/ workspace). if (isDotNetProject && options.AddJsBindings) { logger.LogError( @@ -150,6 +98,36 @@ public async Task SetupWorkspaceAsync(WorkspaceSetupOptions options, Cancel return initializationResult; } + // M2 (round-6): restore on a jsbindings-only workspace (no packages:, + // just jsBindings:) short-circuits the SDK install pipeline and + // forwards to the codegen-regen path. Without this guard the flow + // falls through to InstallPackagesAsync with the default SDK_PACKAGES + // set and then runs cppwinrt — neither of which the user asked for. + if (options.RequireExistingConfig + && !isDotNetProject + && config is not null + && config.Packages.Count == 0 + && config.JsBindings is not null) + { + logger.LogInformation( + "{UISymbol} winapp.yaml has no packages: but declares jsBindings: — regenerating JS bindings.", + UiSymbols.Note); + var generateExit = await jsBindingsWorkspaceService.GenerateAsync( + new GenerateJsBindingsOptions + { + BaseDirectory = options.BaseDirectory, + ConfigDir = options.ConfigDir, + }, + cancellationToken); + if (generateExit == 0) + { + // Parity with init --js-bindings: keep @microsoft/dynwinrt + // wired into package.json after a fresh clone restore. + jsBindingsWorkspaceService.EnsureRuntimeDependencyAndPrintHint(options.BaseDirectory); + } + return generateExit; + } + // Handle config-only mode: just create/validate config file and exit (only for non-.NET path) if (!isDotNetProject && options.ConfigOnly) { @@ -167,14 +145,11 @@ public async Task SetupWorkspaceAsync(WorkspaceSetupOptions options, Cancel } } - // Q3: when re-init adds a jsBindings block to an already-existing - // winapp.yaml (the v1.0 user case), --config-only would otherwise - // skip the save and the user would see no effect. Persist here - // so the freshly-injected jsBindings actually lands on disk. + // Persist re-init's freshly-injected jsBindings even under + // --config-only (otherwise the save would be skipped). if (options.AddJsBindings && config.JsBindings is not null) { - // Splice-save: preserve any user-edited comments + unknown - // fields in the existing yaml. + // Splice-save preserves user comments + unknown fields. configService.SaveJsBindingsOnly(config); logger.LogDebug("{UISymbol} Persisted updated configuration with jsBindings → {ConfigPath}", UiSymbols.Save, configService.ConfigPath); } @@ -203,8 +178,7 @@ public async Task SetupWorkspaceAsync(WorkspaceSetupOptions options, Cancel var finalConfig = new WinappConfig { - // Preserve any JsBindings block the user set (or that --js-bindings - // injected upstream) so re-running init doesn't strip it. + // Preserve JsBindings across re-init. JsBindings = config?.JsBindings, }; foreach (var kvp in defaultVersions) @@ -234,6 +208,15 @@ public async Task SetupWorkspaceAsync(WorkspaceSetupOptions options, Cancel } // else: SdkInstallMode == None and no existing config - nothing to do + // --config-only skips the bindings step entirely, so the M13 + // "defer pkg.json mutation until codegen succeeds" rule has + // nothing to defer past. Update package.json here so the + // npm-caller path still gets @microsoft/dynwinrt wired up. + if (options.AddJsBindings && config?.JsBindings is not null) + { + jsBindingsWorkspaceService.EnsureRuntimeDependencyAndPrintHint(options.BaseDirectory); + } + logger.LogInformation("Configuration-only operation completed"); return 0; } @@ -640,12 +623,9 @@ await taskContext.AddSubTaskAsync("Configuring developer mode", async (taskConte } // Persist the lockfile so subsequent `node jsbindings add` - // can skip re-globbing / re-fetching nuspecs. - // - // Hash source must match what eventually lands in - // winapp.yaml: restore uses config.Packages directly; - // fresh init filters usedVersions to SDK_PACKAGES first - // (same filter applied to yaml at ~line 929). + // can skip re-globbing / re-fetching nuspecs. Hash source + // must match what lands in winapp.yaml (SDK_PACKAGES-filtered + // for fresh init, config.Packages for restore). var yamlHash = (options.RequireExistingConfig && config?.Packages.Count > 0) ? YamlPackagesHasher.Compute(config.Packages) : YamlPackagesHasher.ComputeFromVersions(usedVersions @@ -786,8 +766,7 @@ await taskContext.AddSubTaskAsync("Saving configuration", (taskContext, cancella // Setup: Save winapp.yaml with used versions var finalConfig = new WinappConfig { - // Preserve any JsBindings block the user set (or that --js-bindings - // injected upstream) so the persisted yaml round-trips correctly. + // Preserve JsBindings so the persisted yaml round-trips. JsBindings = config?.JsBindings, }; // only from SDK_PACKAGES @@ -881,30 +860,9 @@ await taskContext.AddSubTaskAsync("Updating Directory.Packages.props", (taskCont }, cancellationToken); } - // Selects the .csproj file to configure when multiple are found. - private async Task SelectCsprojFileAsync(IReadOnlyList csprojFiles, CancellationToken cancellationToken) - { - if (csprojFiles.Count == 1) - { - return csprojFiles[0]; - } - - // Multiple .csproj files found — ask the user which one to use - var choices = csprojFiles.Select(f => f.Name).ToArray(); - var selected = await ansiConsole.PromptAsync( - new SelectionPrompt() - .Title("Multiple .csproj files found. Which project should be configured?") - .AddChoices(choices), - cancellationToken); - return csprojFiles.First(f => f.Name == selected); - } - - // Runs the JS-bindings step when its prerequisites (config, restore - // outputs, workspace dir) are all present. Returns null when skipped - // or when the step succeeded; returns a non-zero (exitCode, message) - // tuple when the step ran and failed, which the caller forwards as - // the overall init/restore result. Internal so unit tests can exercise - // it directly with a fake IJsBindingsWorkspaceService. + // Runs the JS-bindings step when prerequisites are present. + // Returns null on skip/success; non-zero tuple on failure (forwarded to caller). + // Internal so unit tests can drive it with a fake IJsBindingsWorkspaceService. internal async Task<(int, string)?> MaybeRunJsBindingsStepAsync( WinappConfig? config, Dictionary? usedVersions, @@ -939,9 +897,8 @@ private async Task SelectCsprojFileAsync(IReadOnlyList cspro return (orchResult.ExitCode, orchResult.Message); }, cancellationToken); - // Propagate sub-task failure to the parent init/restore flow. - // Otherwise init reports overall success even when bindings - // didn't generate — silently shipping a broken workspace. + // Propagate failure so init doesn't report success while shipping + // a broken workspace. if (jsBindingsResult.Item1 != 0) { return jsBindingsResult; @@ -979,752 +936,4 @@ await manifestService.GenerateManifestAsync( } }, cancellationToken); } - - private async Task<(int ReturnCode, WinappConfig? Config, bool HadExistingConfig, bool ShouldGenerateManifest, ManifestGenerationInfo? ManifestGenerationInfo, bool ShouldEnableDeveloperMode, string? RecommendedTfm)> InitializeConfigurationAsync(WorkspaceSetupOptions options, bool isDotNetProject, FileInfo? csprojFile, CancellationToken cancellationToken) - { - if (!options.RequireExistingConfig && !options.ConfigOnly && options.SdkInstallMode == null && options.UseDefaults) - { - // Default to Stable when --use-defaults - options.SdkInstallMode = SdkInstallMode.Stable; - } - - var hadExistingConfig = configService.Exists(); - bool shouldGenerateManifest = true; - bool shouldEnableDeveloperMode = false; - string? recommendedTfm = null; - ManifestGenerationInfo? manifestGenerationInfo = null; - WinappConfig? config = null; - - // Step 1: Handle configuration requirements - if (options.RequireExistingConfig && !configService.Exists()) - { - // Non-.NET project with no winapp.yaml — nothing to restore. - // (.NET projects without yaml are handled earlier in SetupWorkspaceAsync.) - // This is a no-op rather than an error: a project that doesn't declare - // SDK package versions in winapp.yaml has nothing for restore to do. - logger.LogInformation("{UISymbol} No winapp.yaml found in {ConfigDir}. Nothing to restore.", UiSymbols.Note, options.ConfigDir); - logger.LogInformation("If this project needs Windows SDK packages, run 'winapp init' to set them up."); - return (0, config, hadExistingConfig, shouldGenerateManifest, manifestGenerationInfo, shouldEnableDeveloperMode, recommendedTfm); - } - - // Step 2: Load or prepare configuration - if (hadExistingConfig) - { - config = configService.Load(); - - if (config.Packages.Count == 0 && options.RequireExistingConfig) - { - logger.LogInformation("{UISymbol} winapp.yaml found but contains no packages. Nothing to restore.", UiSymbols.Note); - shouldEnableDeveloperMode = await AskShouldEnableDeveloperModeAsync(options, cancellationToken); - return (0, config, hadExistingConfig, shouldGenerateManifest, manifestGenerationInfo, shouldEnableDeveloperMode, recommendedTfm); - } - - var operation = options.RequireExistingConfig ? "Found" : "Found existing"; - logger.LogDebug("{UISymbol} {Operation} winapp.yaml with {PackageCount} packages", UiSymbols.Package, operation, config.Packages.Count); - - // Re-init UX: surface the JS bindings capability when the user - // hasn't opted in yet. npm-shim only — winget users can't use - // --js-bindings. - if (!options.RequireExistingConfig - && !options.AddJsBindings - && config.JsBindings is null - && string.Equals( - Environment.GetEnvironmentVariable("WINAPP_CLI_CALLER"), - "nodejs-package", - StringComparison.Ordinal)) - { - // Informational hint — must respect --quiet. - logger.LogInformation( - "{UISymbol} To add JS/TS bindings to this project, re-run: npx winapp init --js-bindings", - UiSymbols.Info); - } - - if (!options.RequireExistingConfig && config.Packages.Count > 0) - { - logger.LogDebug("{UISymbol} Using pinned package versions from winapp.yaml unless overridden.", UiSymbols.Note); - } - - // For setup command: ask about overwriting existing config (only if not skipping SDK installation and not config-only mode) - if (!options.RequireExistingConfig && !options.IgnoreConfig && !options.ConfigOnly && options.SdkInstallMode != SdkInstallMode.None && config.Packages.Count > 0) - { - if (options.UseDefaults) - { - options.IgnoreConfig = true; - } - else - { - var overwriteConfig = await ShowConfirmationPromptAsync(ansiConsole, "winapp.yaml exists with pinned versions. Overwrite?", cancellationToken); - shouldGenerateManifest = await AskShouldGenerateManifestAsync(options, cancellationToken); - if (shouldGenerateManifest) - { - manifestGenerationInfo = await PromptForManifestInfoAsync(options, cancellationToken); - } - if (!overwriteConfig) - { - options.IgnoreConfig = true; - } - else - { - await AskSdkInstallModeAsync(options, isDotNetProject, csprojFile, cancellationToken); - } - } - } - } - else - { - shouldGenerateManifest = await AskShouldGenerateManifestAsync(options, cancellationToken); - if (shouldGenerateManifest) - { - manifestGenerationInfo = await PromptForManifestInfoAsync(options, cancellationToken); - } - - await AskSdkInstallModeAsync(options, isDotNetProject, csprojFile, cancellationToken); - if (options.SdkInstallMode != SdkInstallMode.None) - { - config = new WinappConfig(); - logger.LogDebug("{UISymbol} No winapp.yaml found; will generate one after setup.", UiSymbols.New); - } - } - - // Re-check after AskSdkInstallModeAsync: the interactive prompt can - // land on SdkInstallMode=None, which silently breaks --js-bindings - // (codegen has no packages to walk). - if (options.AddJsBindings && options.SdkInstallMode == SdkInstallMode.None) - { - logger.LogError( - "{UISymbol} --js-bindings requires SDK packages but the SDK install mode was set to 'none'. " + - "Re-run without --js-bindings, or pick a non-'none' SDK mode (stable / preview / experimental).", - UiSymbols.Error); - return (1, config, hadExistingConfig, shouldGenerateManifest, manifestGenerationInfo, shouldEnableDeveloperMode, recommendedTfm); - } - - // --js-bindings: fill in a default block when none exists. Never - // overwrites a user-defined block. - if (options.AddJsBindings && config != null && config.JsBindings is not null) - { - // Warn when override flags were passed but the yaml already had - // a jsBindings block — we won't apply them silently. - var hasOverrides = !string.IsNullOrWhiteSpace(options.JsBindingsOutputOverride) - || !string.IsNullOrWhiteSpace(options.JsBindingsLangOverride) - || (options.JsBindingsPresets is { Count: > 0 }); - if (hasOverrides) - { - logger.LogWarning( - "{UISymbol} --js-bindings-output / --js-bindings-lang / --js-bindings-{{preset}} are " + - "ignored because winapp.yaml already declares a jsBindings block. " + - "Use 'npx winapp node jsbindings add --force' to overwrite specific fields.", - UiSymbols.Warning); - } - } - if (options.AddJsBindings && config != null && config.JsBindings is null) - { - var jsCfg = new JsBindingsConfig(); - if (!string.IsNullOrWhiteSpace(options.JsBindingsOutputOverride)) - { - jsCfg.Output = options.JsBindingsOutputOverride!.Trim(); - } - if (!string.IsNullOrWhiteSpace(options.JsBindingsLangOverride)) - { - jsCfg.Lang = options.JsBindingsLangOverride!.Trim(); - } - - // Validate the resolved output path before persisting anything — - // mirrors the add-jsbindings path and prevents an invalid - // --js-bindings-output value from corrupting winapp.yaml. - try - { - DynWinrtCodegenService.ResolveOutputDir(options.BaseDirectory, jsCfg.Output); - } - catch (InvalidOperationException ex) - { - logger.LogError( - "{UISymbol} Invalid --js-bindings-output: {Reason}", - UiSymbols.Error, ex.Message); - return (1, config, hadExistingConfig, shouldGenerateManifest, manifestGenerationInfo, shouldEnableDeveloperMode, recommendedTfm); - } - if (options.JsBindingsPresets is { Count: > 0 } presetNames) - { - var packageIds = JsBindingsPresets.ResolveAndUnion(presetNames); - if (packageIds.Count > 0) - { - jsCfg.Packages = new List(packageIds); - logger.LogDebug( - "{UISymbol} jsBindings presets [{Presets}] → packages=[{Packages}]", - UiSymbols.New, - string.Join(", ", presetNames), - string.Join(", ", packageIds)); - } - else - { - // Defensive: InitCommand validates preset names before - // this is reached. - logger.LogWarning( - "{UISymbol} jsBindings presets [{Presets}] resolved to no prefixes; ignoring (known: {Known}).", - UiSymbols.Warning, - string.Join(", ", presetNames), - JsBindingsPresets.KnownPresetsDisplay()); - } - } - config.JsBindings = jsCfg; - logger.LogDebug( - "{UISymbol} --js-bindings: added default jsBindings block (lang={Lang}, output={Output})", - UiSymbols.New, - config.JsBindings.Lang, - config.JsBindings.Output); - - // Generated bindings import @microsoft/dynwinrt at runtime — must - // be a production dep (not just a transitive of the devDep - // wrapper). Print a PM-aware install hint. - jsBindingsWorkspaceService.EnsureRuntimeDependencyAndPrintHint(options.BaseDirectory); - } - - // .NET: Validate TargetFramework (interactive) - if (isDotNetProject && csprojFile != null) - { - if (dotNetService.IsMultiTargeted(csprojFile)) - { - logger.LogError("The project '{CsprojFile}' uses multi-targeting (TargetFrameworks). winapp init does not support multi-targeted projects.", csprojFile.Name); - return (1, config, hadExistingConfig, shouldGenerateManifest, manifestGenerationInfo, shouldEnableDeveloperMode, recommendedTfm); - } - - var currentTfm = dotNetService.GetTargetFramework(csprojFile); - logger.LogDebug("Current TargetFramework: {Tfm}", currentTfm ?? "(not set)"); - - if (currentTfm == null || !dotNetService.IsTargetFrameworkSupported(currentTfm)) - { - recommendedTfm = dotNetService.GetRecommendedTargetFramework(currentTfm); - - if (!options.UseDefaults) - { - var currentDisplay = currentTfm ?? "(not set)"; - - var promptSuffix = options.SdkInstallMode != SdkInstallMode.None - ? " (Required for Windows App SDK)" - : ""; - - var shouldUpdate = await ShowConfirmationPromptAsync(ansiConsole, $"Update TargetFramework to \"{recommendedTfm}\"{promptSuffix}?", cancellationToken); - - if (!shouldUpdate) - { - if (options.SdkInstallMode != SdkInstallMode.None) - { - logger.LogError("TargetFramework '{Tfm}' is not supported for Windows App SDK. Cannot continue.", currentDisplay); - return (1, config, hadExistingConfig, shouldGenerateManifest, manifestGenerationInfo, shouldEnableDeveloperMode, recommendedTfm); - } - - // Not installing SDKs, so TFM update is not required — skip it - recommendedTfm = null; - } - } - else - { - var currentDisplay = currentTfm ?? "(not set)"; - logger.LogWarning( - "TargetFramework '{CurrentTfm}' is not supported for Windows App SDK. Automatically updating to '{RecommendedTfm}' because --use-defaults was specified.", - currentDisplay, - recommendedTfm); - logger.LogInformation("Automatically updating TargetFramework from {CurrentTfm} to {RecommendedTfm} because --use-defaults was specified.", Markup.Escape(currentDisplay), recommendedTfm); - } - } - else - { - logger.LogDebug("{UISymbol} TargetFramework '{Tfm}' is supported", UiSymbols.Check, currentTfm); - } - } - - shouldEnableDeveloperMode = await AskShouldEnableDeveloperModeAsync(options, cancellationToken); - - return (0, config, hadExistingConfig, shouldGenerateManifest, manifestGenerationInfo, shouldEnableDeveloperMode, recommendedTfm); - } - - private static async Task ShowConfirmationPromptAsync(IAnsiConsole ansiConsole, string prompt, CancellationToken cancellationToken) - { - var result = await ansiConsole.PromptAsync(new ConfirmationPrompt(prompt), cancellationToken); - - ansiConsole.Cursor.MoveUp(); - ansiConsole.Write("\x1b[2K"); // Clear line - ansiConsole.MarkupLine($"{prompt}: [underline]{(result ? "Yes" : "No")}[/]"); - - return result; - } - - private async Task PromptForManifestInfoAsync(WorkspaceSetupOptions options, CancellationToken cancellationToken) - { - if (options.ConfigOnly) - { - return null; - } - - return await manifestService.PromptForManifestInfoAsync(options.BaseDirectory, null, null, "1.0.0.0", "Windows Application", null, options.UseDefaults, cancellationToken); - } - - private async Task AskShouldEnableDeveloperModeAsync(WorkspaceSetupOptions options, CancellationToken cancellationToken) - { - if (options.ConfigOnly || options.RequireExistingConfig) - { - return false; - } - - if (devModeService.IsEnabled()) - { - return false; - } - - if (options.UseDefaults) - { - return false; - } - - return await ShowConfirmationPromptAsync(ansiConsole, "Enable Developer Mode (requires elevation and you will be prompted by User Account Control)", cancellationToken); - } - - private async Task AskShouldGenerateManifestAsync(WorkspaceSetupOptions options, CancellationToken cancellationToken) - { - if (options.RequireExistingConfig) - { - return true; - } - - // Check if manifest already exists, and if so, ask about overwriting - var manifestPath = MsixService.FindProjectManifest(currentDirectoryProvider, options.BaseDirectory); - if ((manifestPath?.Exists) == true) - { - logger.LogDebug("{UISymbol} {ManifestFileName} already exists at {ManifestPath}", UiSymbols.Check, manifestPath.Name, manifestPath.FullName); - if (options.UseDefaults) - { - // With --use-defaults, skip overwriting existing manifest (non-destructive) - return false; - } - else - { - return await ShowConfirmationPromptAsync(ansiConsole, $"{manifestPath.Name} already exists. Overwrite?", cancellationToken); - } - } - - return true; - } - - private async Task AskSdkInstallModeAsync(WorkspaceSetupOptions options, bool isDotNetProject, FileInfo? csprojFile, CancellationToken cancellationToken) - { - // For init (not restore), prompt for SDK installation choice if not specified - if (!options.RequireExistingConfig && !options.ConfigOnly && options.SdkInstallMode == null) - { - // If the .NET project already references WinAppSDK, skip the prompt and default to None. - // This call may take a while on a fresh machine because `dotnet list package` triggers - // an implicit restore — surface a spinner so the user knows we're doing something (#463). - if (isDotNetProject && csprojFile != null) - { - var alreadyReferencesWinAppSdk = await RunWithStatusAsync( - "Detecting project SDK references...", - ct => dotNetService.HasPackageReferenceAsync(csprojFile, DotNetService.WINAPP_SDK_NUGET_PACKAGE, ct), - cancellationToken); - if (alreadyReferencesWinAppSdk) - { - options.SdkInstallMode = SdkInstallMode.None; - logger.LogInformation("{UISymbol} Project already references {PackageName}; skipping Windows App SDK setup.", UiSymbols.Check, DotNetService.WINAPP_SDK_NUGET_PACKAGE); - return; - } - } - // Determine which packages to show versions for - var packages = isDotNetProject - ? [BuildToolsService.WINAPP_SDK_PACKAGE] - : new[] { BuildToolsService.CPP_SDK_PACKAGE, BuildToolsService.WINAPP_SDK_PACKAGE }; - - // Fetch versions for all modes in parallel (failures are non-fatal). On a fresh machine - // these NuGet feed calls can take many seconds; show a spinner so the prompt doesn't - // appear to hang (#463). - var modes = new[] { SdkInstallMode.Stable, SdkInstallMode.Preview, SdkInstallMode.Experimental }; - var versionTasks = await RunWithStatusAsync( - "Fetching latest SDK versions...", - async ct => - { - var tasks = modes - .SelectMany(mode => packages.Select(pkg => (Mode: mode, Package: pkg, Task: SafeGetLatestVersionAsync(pkg, mode, ct)))) - .ToList(); - await Task.WhenAll(tasks.Select(v => v.Task)); - return tasks; - }, - cancellationToken); - - // Build a lookup: (mode) → version label - var versionsByMode = modes.ToDictionary( - mode => mode, - mode => - { - var parts = versionTasks - .Where(v => v.Mode == mode && v.Task.Result != null) - .Select(v => $"{(v.Package == BuildToolsService.CPP_SDK_PACKAGE ? "Windows SDK" : "Windows App SDK")} [green]{v.Task.Result}[/]"); - return string.Join(", ", parts); - }); - - var label = isDotNetProject ? "Windows App SDK" : "SDKs"; - string FormatChoice(string modeLabel, SdkInstallMode mode) - { - var versions = versionsByMode[mode]; - return string.IsNullOrEmpty(versions) - ? $"Setup {modeLabel} {label}" - : $"Setup {modeLabel} {label} ({versions})"; - } - string[] sdkChoices = [ - FormatChoice("Stable", SdkInstallMode.Stable), - FormatChoice("Preview", SdkInstallMode.Preview), - FormatChoice("Experimental", SdkInstallMode.Experimental), - $"Do not setup {label}" - ]; - - ansiConsole.WriteLine($"Select {label} setup option:"); - var sdkPrompt = new SelectionPrompt() - .AddChoices(sdkChoices); - - var sdkChoice = await ansiConsole.PromptAsync(sdkPrompt, cancellationToken); - - ansiConsole.Cursor.MoveUp(); - ansiConsole.Write("\x1b[2K"); // Clear line - - if (sdkChoice == sdkChoices[0]) - { - options.SdkInstallMode = SdkInstallMode.Stable; - } - else if (sdkChoice == sdkChoices[1]) - { - options.SdkInstallMode = SdkInstallMode.Preview; - } - else if (sdkChoice == sdkChoices[2]) - { - options.SdkInstallMode = SdkInstallMode.Experimental; - } - else - { - options.SdkInstallMode = SdkInstallMode.None; - logger.LogInformation("Setup {Label}: Do not setup {Label}", label, label); - return; - } - - ansiConsole.MarkupLine($"Setup {label}: [underline]{Markup.Remove(sdkChoice["Setup ".Length..])}[/]"); - } - } - - private async Task SafeGetLatestVersionAsync(string packageName, SdkInstallMode mode, CancellationToken cancellationToken) - { - try - { - return await nugetService.GetLatestVersionAsync(packageName, sdkInstallMode: mode, cancellationToken: cancellationToken); - } - catch (Exception ex) - { - logger.LogDebug("Failed to fetch latest version for {PackageName} ({Mode}): {ErrorMessage}", packageName, mode, ex.Message); - return null; - } - } - - // Package entry information from MSIX inventory - public class MsixPackageEntry - { - public required string FileName { get; set; } - public required string PackageIdentity { get; set; } - } - - // Parses the MSIX inventory file and returns package entries (shared implementation) - public static async Task?> ParseMsixInventoryAsync(TaskContext taskContext, DirectoryInfo msixDir, CancellationToken cancellationToken) - { - var architecture = GetSystemArchitecture(); - - taskContext.AddDebugMessage($"{UiSymbols.Note} Detected system architecture: {architecture}"); - - // Look for MSIX packages for the current architecture - var msixArchDir = Path.Combine(msixDir.FullName, $"win10-{architecture}"); - if (!Directory.Exists(msixArchDir)) - { - taskContext.AddDebugMessage($"{UiSymbols.Note} No MSIX packages found for architecture {architecture}"); - taskContext.AddDebugMessage($"{UiSymbols.Note} Available directories: {string.Join(", ", msixDir.GetDirectories().Select(d => d.Name))}"); - return null; - } - - // Read the MSIX inventory file - var inventoryPath = Path.Combine(msixArchDir, "msix.inventory"); - if (!File.Exists(inventoryPath)) - { - taskContext.AddDebugMessage($"{UiSymbols.Note} No msix.inventory file found in {msixArchDir}"); - return null; - } - - var inventoryLines = await File.ReadAllLinesAsync(inventoryPath, cancellationToken); - var packageEntries = inventoryLines - .Where(line => !string.IsNullOrWhiteSpace(line) && line.Contains('=')) - .Select(line => line.Split('=', 2)) - .Where(parts => parts.Length == 2) - .Select(parts => new MsixPackageEntry { FileName = parts[0].Trim(), PackageIdentity = parts[1].Trim() }) - .ToList(); - - if (packageEntries.Count == 0) - { - taskContext.AddDebugMessage($"{UiSymbols.Note} No valid package entries found in msix.inventory"); - return null; - } - - taskContext.AddDebugMessage($"{UiSymbols.Package} Found {packageEntries.Count} MSIX packages in inventory"); - - return packageEntries; - } - - // Reads the actual package Name and Version from the AppxManifest.xml inside an MSIX file. - // The MSIX inventory file can have incorrect package names (e.g., the DDLM), so we read - // the real identity directly from the package to ensure correct installation checks. - private static (string? Name, string? Version) ReadMsixIdentity(string msixFilePath, TaskContext taskContext) - { - try - { - using var zip = System.IO.Compression.ZipFile.OpenRead(msixFilePath); - var manifestEntry = zip.GetEntry("AppxManifest.xml"); - if (manifestEntry == null) - { - return (null, null); - } - - using var stream = manifestEntry.Open(); - var doc = System.Xml.Linq.XDocument.Load(stream); - var identityElement = doc.Root?.Elements() - .FirstOrDefault(e => e.Name.LocalName == "Identity"); - - var name = identityElement?.Attribute("Name")?.Value; - var version = identityElement?.Attribute("Version")?.Value; - return (name, version); - } - catch (Exception ex) - { - taskContext.AddDebugMessage($"{UiSymbols.Note} Could not read identity from {Path.GetFileName(msixFilePath)}: {ex.Message}"); - return (null, null); - } - } - - // Installs Windows App SDK runtime MSIX packages for the current system architecture - public async Task<(int InstalledCount, int ErrorCount)> InstallWindowsAppRuntimeAsync(DirectoryInfo msixDir, TaskContext taskContext, CancellationToken cancellationToken) - { - var architecture = GetSystemArchitecture(); - - // Get package entries from MSIX inventory - var packageEntries = await ParseMsixInventoryAsync(taskContext, msixDir, cancellationToken); - if (packageEntries == null || packageEntries.Count == 0) - { - return (0, 0); - } - - var msixArchDir = Path.Combine(msixDir.FullName, $"win10-{architecture}"); - - // Build list of packages to evaluate - var packagesToCheck = new List<(string FilePath, string PackageName, string NewVersion, string FileName)>(); - foreach (var entry in packageEntries) - { - var msixFilePath = Path.Combine(msixArchDir, entry.FileName); - if (!File.Exists(msixFilePath)) - { - taskContext.AddDebugMessage($"{UiSymbols.Note} MSIX file not found: {msixFilePath}"); - continue; - } - - // Read the actual package identity from the MSIX's AppxManifest.xml. - // The inventory file's PackageIdentity can differ from the real installed name. - var (packageName, newVersionString) = ReadMsixIdentity(msixFilePath, taskContext); - if (packageName == null) - { - // Fallback: parse from inventory identity string - var identityParts = entry.PackageIdentity.Split('_'); - packageName = identityParts[0]; - newVersionString = identityParts.Length >= 2 ? identityParts[1] : ""; - } - - packagesToCheck.Add((msixFilePath, packageName, newVersionString ?? "", entry.FileName)); - } - - if (packagesToCheck.Count == 0) - { - return (0, 0); - } - - taskContext.AddDebugMessage($"{UiSymbols.Info} Checking and installing {packagesToCheck.Count} MSIX packages"); - - var installedCount = 0; - var errorCount = 0; - - foreach (var (filePath, packageName, newVersion, fileName) in packagesToCheck) - { - // Check if already installed with same or newer version - var installedVersion = packageRegistrationService.GetInstalledVersion(packageName); - if (installedVersion != null) - { - if (Version.TryParse(installedVersion, out var existing) && - Version.TryParse(newVersion, out var incoming) && - existing >= incoming) - { - taskContext.AddDebugMessage($"{UiSymbols.Check} {fileName}: Already installed or newer version exists"); - continue; - } - } - - taskContext.AddDebugMessage($"{UiSymbols.Info} {fileName}: Will install"); - - try - { - await packageRegistrationService.InstallPackageAsync(filePath, cancellationToken); - installedCount++; - taskContext.AddDebugMessage($"{UiSymbols.Check} {fileName}: Installation successful"); - } - catch (Exception ex) - { - errorCount++; - taskContext.AddDebugMessage($"{UiSymbols.Note} {fileName}: {ex.Message}"); - } - } - - // Provide summary feedback - if (installedCount > 0) - { - taskContext.AddDebugMessage($"{UiSymbols.Check} Installed {installedCount} MSIX packages"); - } - if (errorCount > 0) - { - taskContext.AddDebugMessage($"{UiSymbols.Note} {errorCount} packages failed to install"); - } - - return (installedCount, errorCount); - } - - // Gets the current system architecture string for package selection - public static string GetSystemArchitecture() - { - var arch = RuntimeInformation.ProcessArchitecture; - return arch switch - { - Architecture.X64 => "x64", - Architecture.Arm64 => "arm64", - Architecture.X86 => "x86", - _ => "x64" // Default fallback - }; - } - - // Finds the MSIX directory for Windows App SDK runtime packages - public DirectoryInfo? FindWindowsAppSdkMsixDirectory(Dictionary? usedVersions = null) - { - var nugetCacheDir = nugetService.GetNuGetGlobalPackagesDir(); - return FindMsixDirectoryInNuGetCache(nugetCacheDir, usedVersions); - } - - // Searches the NuGet global packages cache (lowercase id/version folder convention). - private static DirectoryInfo? FindMsixDirectoryInNuGetCache(DirectoryInfo nugetCacheDir, Dictionary? usedVersions) - { - if (usedVersions != null) - { - // Try runtime package first (Windows App SDK 1.8+) - if (usedVersions.TryGetValue(BuildToolsService.WINAPP_SDK_RUNTIME_PACKAGE, out var runtimeVersion)) - { - var msixDir = TryGetMsixDirectoryFromNuGetCache(nugetCacheDir, BuildToolsService.WINAPP_SDK_RUNTIME_PACKAGE, runtimeVersion); - if (msixDir != null) - { - return msixDir; - } - } - - // Fallback to main package - if (usedVersions.TryGetValue(BuildToolsService.WINAPP_SDK_PACKAGE, out var mainVersion)) - { - var msixDir = TryGetMsixDirectoryFromNuGetCache(nugetCacheDir, BuildToolsService.WINAPP_SDK_PACKAGE, mainVersion); - if (msixDir != null) - { - return msixDir; - } - } - } - - // General scan: look for any runtime package directories - var runtimeDir = new DirectoryInfo(Path.Combine(nugetCacheDir.FullName, BuildToolsService.WINAPP_SDK_RUNTIME_PACKAGE.ToLowerInvariant())); - if (runtimeDir.Exists) - { - foreach (var versionDir in runtimeDir.GetDirectories().OrderByDescending(d => d.Name, new VersionStringComparer())) - { - var msixDir = TryGetMsixDirectoryFromPath(versionDir); - if (msixDir != null) - { - return msixDir; - } - } - } - - // Fallback: main package - var mainDir = new DirectoryInfo(Path.Combine(nugetCacheDir.FullName, BuildToolsService.WINAPP_SDK_PACKAGE.ToLowerInvariant())); - if (mainDir.Exists) - { - foreach (var versionDir in mainDir.GetDirectories().OrderByDescending(d => d.Name, new VersionStringComparer())) - { - var msixDir = TryGetMsixDirectoryFromPath(versionDir); - if (msixDir != null) - { - return msixDir; - } - } - } - - return null; - } - - // Checks the NuGet cache for a specific package/version (lowercase ID/version layout). - private static DirectoryInfo? TryGetMsixDirectoryFromNuGetCache(DirectoryInfo nugetCacheDir, string packageId, string version) - { - // NuGet global cache uses lowercase package IDs - var pkgVersionDir = new DirectoryInfo(Path.Combine(nugetCacheDir.FullName, packageId.ToLowerInvariant(), version)); - return TryGetMsixDirectoryFromPath(pkgVersionDir); - } - - // Helper method to check if an MSIX directory exists for a given package path - private static DirectoryInfo? TryGetMsixDirectoryFromPath(DirectoryInfo packagePath) - { - var msixDir = new DirectoryInfo(Path.Combine(packagePath.FullName, "tools", "MSIX")); - return msixDir.Exists ? msixDir : null; - } - - // Runs work while showing a Spectre.Console spinner with message. - // In non-interactive contexts (redirected output, no Information logging), falls back to a single - // log line so the user still sees what's happening (#463). - private async Task RunWithStatusAsync(string message, Func> work, CancellationToken cancellationToken) - { - if (Environment.UserInteractive - && !Console.IsOutputRedirected - && logger.IsEnabled(LogLevel.Information) - && ansiConsole.Profile.Capabilities.Interactive) - { - T result = default!; - await ansiConsole.Status() - .Spinner(Spinner.Known.Dots) - .StartAsync(message, async _ => - { - result = await work(cancellationToken); - }); - return result; - } - - logger.LogInformation("{Message}", message); - return await work(cancellationToken); - } - - // Comparer for sorting version strings, including prerelease support - private class VersionStringComparer : IComparer - { - public int Compare(string? x, string? y) - { - if (x == null && y == null) - { - return 0; - } - if (x == null) - { - return -1; - } - if (y == null) - { - return 1; - } - - // Use the same comparison logic as NugetService.CompareVersions - return NugetService.CompareVersions(x, y); - } - } } diff --git a/src/winapp-VSC/README.md b/src/winapp-VSC/README.md index 9181f5a4..74d91a0a 100644 --- a/src/winapp-VSC/README.md +++ b/src/winapp-VSC/README.md @@ -42,6 +42,8 @@ All commands are accessible from the Command Palette (`Ctrl+Shift+P`). Type **Wi | **WinApp: Run SDK Tool** | Run Windows SDK tools (`makeappx`, `signtool`, `mt`, `makepri`) with custom arguments. | | **WinApp: Get WinApp Path** | Show paths to installed SDK components. | +> **Note:** This extension exposes the native winapp CLI commands listed above. Node.js–specific subcommands provided by the [`@microsoft/winappcli` npm package](https://www.npmjs.com/package/@microsoft/winappcli) (`winapp node create-addon`, `winapp node jsbindings …`, etc.) are intentionally not surfaced in the Command Palette — install and use the npm package directly for those workflows. + ### Integrated Debugging The extension provides a **custom `winapp` debug type** that launches your app with package identity and automatically attaches the appropriate debugger — all from a single **F5** press. diff --git a/src/winapp-npm/src/cli.ts b/src/winapp-npm/src/cli.ts index 4fb268ad..2c79b611 100644 --- a/src/winapp-npm/src/cli.ts +++ b/src/winapp-npm/src/cli.ts @@ -86,7 +86,17 @@ async function handleNodeCommand(command: string, args: string[]): Promise // Node.js wrapper-only commands that should appear in completions const NODE_WRAPPER_COMMANDS = ['node']; -const NODE_SUBCOMMANDS = ['jsbindings', 'create-addon', 'add-electron-debug-identity', 'clear-electron-debug-identity']; +// `js-bindings` is a kebab-case alias for `jsbindings` exposed by the +// native CLI (JsBindingsCommand.Aliases); keep the wrapper in sync so +// users following the kebab-case convention from the init flag +// (`--js-bindings`) don't get rejected with "Unknown node subcommand". +const NODE_SUBCOMMANDS = [ + 'jsbindings', + 'js-bindings', + 'create-addon', + 'add-electron-debug-identity', + 'clear-electron-debug-identity', +]; /** * Handle completion requests by forwarding to the native CLI and augmenting @@ -204,12 +214,17 @@ async function showCombinedHelp(): Promise { console.log(''); console.log('Node.js Subcommands:'); console.log(' node create-addon Generate native addon files for Electron'); + console.log(' node jsbindings add Edit winapp.yaml to declare JS/TS bindings'); + console.log(' node jsbindings generate Regenerate JS/TS bindings from winapp.yaml'); console.log(' node add-electron-debug-identity Add package identity to Electron debug process'); console.log(' node clear-electron-debug-identity Remove package identity from Electron debug process'); + console.log(' (jsbindings is also spelled js-bindings — both forms work.)'); console.log(''); console.log('Examples:'); console.log(` ${CLI_NAME} node create-addon --name myAddon`); console.log(` ${CLI_NAME} node create-addon --template cs --name myAddon`); + console.log(` ${CLI_NAME} node jsbindings add --ai`); + console.log(` ${CLI_NAME} node jsbindings generate`); console.log(` ${CLI_NAME} node add-electron-debug-identity`); console.log(` ${CLI_NAME} node clear-electron-debug-identity`); } @@ -272,6 +287,7 @@ async function handleNode(args: string[]): Promise { console.log(' create-addon Generate native addon files for Electron'); console.log(' add-electron-debug-identity Add package identity to Electron debug process'); console.log(' clear-electron-debug-identity Remove package identity from Electron debug process'); + console.log(' (jsbindings is also spelled js-bindings — both forms work.)'); console.log(''); console.log('Examples:'); console.log(` ${CLI_NAME} node jsbindings add --ai`); @@ -303,13 +319,17 @@ async function handleNode(args: string[]): Promise { break; case 'jsbindings': + case 'js-bindings': // Native-CLI sub-command tree (`node jsbindings add` / `... generate`). // Forward the full argv (including the leading `node`) to the .NET CLI. + // Both `jsbindings` and `js-bindings` (kebab-case alias matching the + // `--js-bindings` init flag) are forwarded unchanged — the native CLI + // accepts both via JsBindingsCommand.Aliases. await callWinappCli(['node', ...args], { exitOnError: true }); break; default: - console.error(`❌ Unknown node subcommand: ${subcommand}`); + console.error(`Unknown node subcommand: ${subcommand}`); console.error(`Run "${CLI_NAME} node" for available subcommands.`); process.exit(1); } @@ -354,7 +374,7 @@ async function handleCreateAddon(args: string[]): Promise { // Validate template if (!['cpp', 'cs'].includes(options.template as string)) { - console.error(`❌ Invalid template: ${options.template}. Valid options: cpp, cs`); + console.error(`Invalid template: ${options.template}. Valid options: cpp, cs`); process.exit(1); } From 6506f987047f1cea216bed95f90dff9254fcaff8 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Wed, 20 May 2026 13:59:24 +0800 Subject: [PATCH 08/27] remove jsbindings options --- .github/plugin/agents/winapp.agent.md | 32 +- .../skills/winapp-cli/frameworks/SKILL.md | 13 +- .../plugin/skills/winapp-cli/setup/SKILL.md | 59 +- .../skills/winapp-cli/ui-automation/SKILL.md | 2 +- README.md | 2 - docs/cli-schema.json | 250 +--- .../fragments/skills/winapp-cli/frameworks.md | 13 +- docs/fragments/skills/winapp-cli/setup.md | 17 +- docs/guides/electron/index.md | 2 +- docs/guides/electron/jsbindings.md | 62 +- docs/js-bindings.md | 225 ++-- docs/npm-usage.md | 80 +- docs/usage.md | 106 +- samples/electron/test.Tests.ps1 | 47 +- scripts/generate-llm-docs.ps1 | 2 +- .../AddJsBindingsCommandTests.cs | 452 -------- .../AddJsBindingsOrchestrationTests.cs | 1016 ----------------- .../GenerateJsBindingsCommandTests.cs | 162 --- .../WinApp.Cli.Tests/InitCommandTests.cs | 445 ++------ .../JsBindingsPresetsTests.cs | 99 -- .../FakeJsBindingsWorkspaceService.cs | 16 - .../WinappConfigDocumentTests.cs | 2 +- .../WorkspaceSetupServiceTests.cs | 115 -- .../Commands/AddJsBindingsCommand.cs | 158 --- .../Commands/GenerateJsBindingsCommand.cs | 82 -- .../WinApp.Cli/Commands/InitCommand.cs | 109 +- .../WinApp.Cli/Commands/JsBindingsCommand.cs | 27 - .../WinApp.Cli/Commands/NodeCommand.cs | 23 - .../WinApp.Cli/Commands/WinAppRootCommand.cs | 5 +- .../Helpers/HostBuilderExtensions.cs | 5 - .../WinApp.Cli/Models/WinappConfig.cs | 6 + .../WinApp.Cli/Models/WinmdsLockfile.cs | 7 +- .../Services/IJsBindingsWorkspaceService.cs | 21 +- .../WinApp.Cli/Services/JsBindingsPresets.cs | 64 +- .../Services/JsBindingsWorkspaceService.cs | 305 ----- .../Services/WinappConfigDocument.cs | 46 + .../Services/WorkspaceSetupService.Init.cs | 126 +- .../Services/WorkspaceSetupService.Options.cs | 42 +- .../Services/WorkspaceSetupService.Prompts.cs | 76 ++ .../Services/WorkspaceSetupService.cs | 182 ++- src/winapp-VSC/README.md | 2 +- src/winapp-npm/README.md | 4 +- src/winapp-npm/scripts/generate-commands.mjs | 7 +- src/winapp-npm/src/cli.ts | 32 +- src/winapp-npm/src/winapp-commands.ts | 68 +- 45 files changed, 560 insertions(+), 4056 deletions(-) delete mode 100644 src/winapp-CLI/WinApp.Cli.Tests/AddJsBindingsCommandTests.cs delete mode 100644 src/winapp-CLI/WinApp.Cli.Tests/AddJsBindingsOrchestrationTests.cs delete mode 100644 src/winapp-CLI/WinApp.Cli.Tests/GenerateJsBindingsCommandTests.cs delete mode 100644 src/winapp-CLI/WinApp.Cli/Commands/AddJsBindingsCommand.cs delete mode 100644 src/winapp-CLI/WinApp.Cli/Commands/GenerateJsBindingsCommand.cs delete mode 100644 src/winapp-CLI/WinApp.Cli/Commands/JsBindingsCommand.cs delete mode 100644 src/winapp-CLI/WinApp.Cli/Commands/NodeCommand.cs diff --git a/.github/plugin/agents/winapp.agent.md b/.github/plugin/agents/winapp.agent.md index 215f4dee..21512bcd 100644 --- a/.github/plugin/agents/winapp.agent.md +++ b/.github/plugin/agents/winapp.agent.md @@ -29,7 +29,7 @@ Does the project already have an appxmanifest.xml? ├─ Has winapp.yaml, cloned/pulled but .winapp/ folder is missing? │ └─ winapp restore ├─ Want to add typed JS/TypeScript WinRT bindings to an existing workspace? - │ └─ npx winapp node jsbindings add --ai (or omit --ai for the full surface) + │ └─ Edit winapp.yaml to add `jsBindings: {}`, then run `npx winapp restore` ├─ Want to check for newer SDK versions? │ └─ winapp update ├─ Only need an appxmanifest.xml (no SDKs, no cert, no config)? @@ -92,8 +92,8 @@ Want to inspect or interact with a running app's UI? **Creates:** `winapp.yaml`, `appxmanifest.xml`, `Assets/` folder, `.winapp/` (if SDKs installed) ### `winapp restore [base-directory]` -**Purpose:** Reinstall SDK packages from existing config without changing versions. -**When to use:** After cloning a repo that has `winapp.yaml`, or when the `.winapp/` folder is missing/corrupted. +**Purpose:** Reinstall SDK packages from existing config without changing versions. Also re-runs JS/TS binding codegen when `winapp.yaml` declares a `jsBindings:` block. +**When to use:** After cloning a repo that has `winapp.yaml`, when the `.winapp/` folder is missing/corrupted, or after editing the `jsBindings:` block by hand. **Requires:** `winapp.yaml` ### `winapp update` @@ -102,22 +102,14 @@ Want to inspect or interact with a running app's UI? **Key options:** `--setup-sdks stable|preview|experimental|none` **Requires:** `winapp.yaml` -### `winapp node jsbindings add` (alias: `winapp node js-bindings add`) -**Purpose:** Layer typed JS/TS WinRT bindings (via `@microsoft/dynwinrt-codegen`) onto an existing workspace. -**When to use:** After `winapp init` on a Node/Electron host, when you want callable WinRT APIs without a native build step. -**Key options:** -- `--ai` — limit generation to the AI surface — the `Microsoft.WindowsAppSDK.AI` NuGet package, which projects the `Microsoft.Windows.AI.*` namespaces (the only ships-today preset) -- `--output PATH` — output directory (default `bindings/winrt`); persisted to `winapp.yaml` -- `--force` — patch an existing `jsBindings:` block (overwrites `output` and preset packages; preserves user customisations like `extraTypes` / `additionalWinmds` / `skipPackages`) -- `--config-dir` — directory containing `winapp.yaml` (default: `base-directory`) -**Requires:** `winapp.yaml` already exists; npm-only (run as `npx winapp node jsbindings add`). Never modifies `packages:` or installs SDK packages. - -### `winapp node jsbindings generate` (alias: `winapp node js-bindings generate`) -**Purpose:** Re-run codegen for the existing `jsBindings:` block in `winapp.yaml` without mutating config. -**When to use:** After editing `jsBindings.packages` / `extraTypes` / `additionalWinmds` by hand, after pulling teammates' `winapp.yaml`, or after a `restore` that brought new SDK versions in. -**Key options:** -- `--config-dir` — directory containing `winapp.yaml` (default: `base-directory`) -**Requires:** `winapp.yaml` already has a `jsBindings:` block; npm-only (run as `npx winapp node jsbindings generate`). Does not edit `winapp.yaml` or `package.json`. +### JS/TS bindings (npm-only, via `init` + `restore`) +**Purpose:** Generate typed JS/TS WinRT bindings (via `@microsoft/dynwinrt-codegen`) so Node / Electron apps can call WinRT APIs directly without a native build step. +**When to use:** Inside a Node / Electron project after `npx winapp init`. +**How to enable:** +- **Fresh init via npm shim** (`npx winapp init`) shows an interactive prompt offering **C++ projections**, **JS/TS bindings**, or **Both** (default with `--use-defaults`: Both). Pick JS/Both to wire `jsBindings: {}` (covering the full Windows App SDK) into `winapp.yaml`. +- **Existing workspace:** edit `winapp.yaml` to add a `jsBindings:` block (e.g. `jsBindings: {}` for full SDK; add `cppProjections: false` at the top level to skip cppwinrt). Then run `npx winapp restore` — it re-runs codegen against the existing yaml without modifying it. +- **Re-run codegen** after editing `jsBindings.packages` / `extraTypes` / `additionalWinmds` by hand: `npx winapp restore`. +**Notes:** npm-only — the interactive prompt only fires when invoked through `npx winapp …`. Standalone winget / installer builds do not generate JS bindings. Codegen always auto-injects `@microsoft/dynwinrt` as a production dep into `package.json`. See [JS bindings docs](https://github.com/microsoft/winappcli/blob/main/docs/js-bindings.md) for the full `jsBindings:` schema. ### `winapp package ` (alias: `winapp pack`) **Purpose:** Create an MSIX installer from a built app. @@ -237,7 +229,7 @@ Want to inspect or interact with a running app's UI? ### Electron - **Setup:** `winapp init --use-defaults` → choose your Windows API access path: - - **JS bindings (easiest, npm-only):** `npx winapp init --use-defaults --js-bindings-ai` (or `npx winapp node jsbindings add --ai` on an existing workspace) — generates typed `bindings/winrt/*.{js,d.ts}` for the WinAppSDK AI surface, callable directly from your main/renderer process via dynwinrt. No native build step. + - **JS bindings (easiest, npm-only):** at the `npx winapp init` prompt pick "JS/TS bindings" or "Both" (or pass `--use-defaults` to auto-pick Both). On an existing workspace, add `jsBindings: {}` to `winapp.yaml` and run `npx winapp restore`. Generates typed `bindings/winrt/*.{js,d.ts}` for the full Windows App SDK surface, callable directly from your main/renderer process via dynwinrt. No native build step. - **Native addons:** `winapp node create-addon --template cs` (or `--template cpp`) for C#/C++ addons when you need full WinRT access or stateful native services. - Then: `winapp node add-electron-debug-identity` to enable identity-required APIs. - **Package:** Build with your packager (e.g., Electron Forge), then `winapp package --cert .\devcert.pfx` diff --git a/.github/plugin/skills/winapp-cli/frameworks/SKILL.md b/.github/plugin/skills/winapp-cli/frameworks/SKILL.md index 0440d822..3c1caf64 100644 --- a/.github/plugin/skills/winapp-cli/frameworks/SKILL.md +++ b/.github/plugin/skills/winapp-cli/frameworks/SKILL.md @@ -30,25 +30,24 @@ Use the **npm package** (`@Microsoft/WinAppCli`), **not** the standalone CLI. Th - The native winapp CLI binary bundled inside `node_modules` - A Node.js SDK with helpers for creating native C#/C++ addons - Electron-specific commands under `npx winapp node` -- `node jsbindings add` — generates typed JS/TypeScript WinRT wrappers via dynwinrt (no native build required) +- An interactive bindings prompt during `npx winapp init` that offers **C++ projections**, **JS/TS bindings** (typed JS/TypeScript WinRT wrappers via dynwinrt, no native build required), or **Both** Quick start: ```powershell npm install --save-dev @microsoft/winappcli -npx winapp init --use-defaults --js-bindings-ai # init + generate typed AI bindings in bindings/winrt/ -# (or, if you already initialized the workspace:) -npx winapp node jsbindings add --ai # layer JS bindings onto an existing workspace +npx winapp init --use-defaults # init + generate full Windows App SDK JS bindings AND C++ projections (default: Both) +# (interactive: omit --use-defaults to pick C++ / JS / Both at the prompt) npx winapp node create-addon --template cs # create a C# native addon (for what dynwinrt can't drive — see below) npx winapp node add-electron-debug-identity # register identity for debugging ``` -The `--js-bindings*` flags (and the `node jsbindings add` sub-command) are **npm-only** — they require invocation via the `@microsoft/winappcli` npm package because they pull in `@microsoft/dynwinrt-codegen` as a transitive dep. The winget / standalone install will reject these surfaces with a clear error. +JS/TS bindings (the `jsBindings:` block in `winapp.yaml`) are **npm-only** — they require invocation via the `@microsoft/winappcli` npm package because they pull in `@microsoft/dynwinrt-codegen` as a transitive dep. The bindings prompt does not appear when running the standalone winget CLI. #### Choosing between jsBindings and a native addon The decision is almost entirely about the **shape of the API**, not preference. -**Default: if the API is WinRT (ships in a `.winmd`), use `node jsbindings add`.** That covers nearly everything an Electron app actually calls on Windows — `Microsoft.Windows.*` (Notifications, FilePickers, Sensors, Storage, AI inference like `TextRecognizer` / `LanguageModel`), most of `Windows.*`, and all of `Microsoft.WindowsAppSDK.AI`. dynwinrt's [own scope statement](https://github.com/microsoft/dynwinrt#scope) sums it up as "non-UI WinRT APIs ... headless services from the Windows SDK and WinAppSDK". +**Default: if the API is WinRT (ships in a `.winmd`), pick JS bindings at the `npx winapp init` prompt.** That covers nearly everything an Electron app actually calls on Windows — `Microsoft.Windows.*` (Notifications, FilePickers, Sensors, Storage, AI inference like `TextRecognizer` / `LanguageModel`), most of `Windows.*`, and all of `Microsoft.WindowsAppSDK.AI`. dynwinrt's [own scope statement](https://github.com/microsoft/dynwinrt#scope) sums it up as "non-UI WinRT APIs ... headless services from the Windows SDK and WinAppSDK". **Fall back to `node create-addon` when one of these is true:** @@ -61,7 +60,7 @@ The decision is almost entirely about the **shape of the API**, not preference. It's normal to mix both in one app: jsBindings for the non-UI WinRT surface, a small C# or C++ addon for the one or two Win32 / non-WinRT calls that don't fit. Additional Electron guides: -- [JS bindings reference](https://github.com/microsoft/WinAppCli/blob/main/docs/js-bindings.md) — full `jsBindings:` yaml schema, presets, per-package classification, lockfile +- [JS bindings reference](https://github.com/microsoft/WinAppCli/blob/main/docs/js-bindings.md) — full `jsBindings:` yaml schema, per-package classification, lockfile - [Packaging guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/packaging.md) - [C++ notification addon guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/cpp-notification-addon.md) - [WinML addon guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/winml-addon.md) diff --git a/.github/plugin/skills/winapp-cli/setup/SKILL.md b/.github/plugin/skills/winapp-cli/setup/SKILL.md index 37a63217..8f4f6ffe 100644 --- a/.github/plugin/skills/winapp-cli/setup/SKILL.md +++ b/.github/plugin/skills/winapp-cli/setup/SKILL.md @@ -54,19 +54,22 @@ winapp init --use-defaults --setup-sdks preview ### Add JS/TS bindings for Node / Electron apps (npm only) -When invoked via the `@microsoft/winappcli` npm package, you can generate -typed JS/TS bindings for WinRT APIs alongside the standard init: +When invoked via the `@microsoft/winappcli` npm package (i.e. `npx winapp init` +inside a Node / Electron project), `init` adds an interactive **bindings prompt** +that asks whether to generate **C++ projections**, **JS/TS bindings**, or **Both**. +Picking JS or Both wires a default `jsBindings:` block into `winapp.yaml` (covering +the full Windows App SDK) and runs codegen as part of init: ```powershell -# Initialize with the AI slice of the SDK pre-wired. -npx winapp init --use-defaults --js-bindings-ai +# Interactive — prompted to pick C++ / JS / Both. +npx winapp init -# Or layer bindings onto an already-initialized workspace. -npx winapp node jsbindings add --ai +# Non-interactive — auto-picks "Both" (C++ projections + JS/TS bindings). +npx winapp init --use-defaults # After editing winapp.yaml jsBindings: by hand (or pulling a teammate's # winapp.yaml), regenerate bindings without re-prompting: -npx winapp node jsbindings generate +npx winapp restore ``` Generated files land under `bindings/winrt/` and `@microsoft/dynwinrt` is @@ -178,7 +181,7 @@ For full debugging scenarios and IDE setup, see the [Debugging Guide](https://gi ### `winapp init` -Start here for initializing a Windows app with required setup. Sets up everything needed for Windows app development: creates Package.appxmanifest with default assets, downloads Windows SDK and Windows App SDK packages, and generates projections. When SDK packages are managed (--setup-sdks stable/preview/experimental), also creates winapp.yaml to pin versions for 'restore'/'update'; with --setup-sdks none (e.g., for Rust/Tauri projects that bring their own SDK bindings), no winapp.yaml is created. Interactive by default (use --use-defaults to skip prompts). Use 'restore' instead if you cloned a repo that already has winapp.yaml. Use 'manifest generate' if you only need a manifest, or 'cert generate' if you need a development certificate for code signing. +Start here for initializing a Windows app with required setup. Sets up everything needed for Windows app development: creates Package.appxmanifest with default assets, downloads Windows SDK and Windows App SDK packages, and generates projections. When SDK packages are managed (--setup-sdks stable/preview/experimental), also creates winapp.yaml to pin versions for 'restore'/'update'; with --setup-sdks none (e.g., for Rust/Tauri projects that bring their own SDK bindings), no winapp.yaml is created. When invoked via the @microsoft/winappcli npm package (npx winapp init), additionally asks whether to generate C++ projections, JS/TS bindings, or both. Interactive by default (use --use-defaults to skip prompts). Use 'restore' instead if you cloned a repo that already has winapp.yaml. Use 'manifest generate' if you only need a manifest, or 'cert generate' if you need a development certificate for code signing. #### Arguments @@ -193,10 +196,6 @@ Start here for initializing a Windows app with required setup. Sets up everythin | `--config-dir` | Directory to read/store configuration (default: current directory) | (none) | | `--config-only` | Only handle configuration file operations (create if missing, validate if exists). Skip package installation and other workspace setup steps. | (none) | | `--ignore-config` | Don't use configuration file for version management | (none) | -| `--js-bindings` | Generate JS/TS bindings via dynwinrt-codegen on top of the standard init flow. Adds a 'jsBindings:' block to winapp.yaml so the binding generator runs as part of init/restore. Only available when invoked via the @microsoft/winappcli npm package (npx winapp init --js-bindings). | (none) | -| `--js-bindings-ai` | Generate bindings for the 'ai' slice of the SDK. Implies --js-bindings (no need to pass it separately). For a custom slice that no preset covers, edit winapp.yaml and write your own packages: list under jsBindings. Known presets: ai. | (none) | -| `--js-bindings-lang` | Override the JS bindings language. Currently only 'js' is supported (which emits both .js and .d.ts). Reserved for forward-compat; see --js-bindings-output for activation rules. | (none) | -| `--js-bindings-output` | Override the output directory for generated JS/TS bindings (relative to workspace, default 'bindings/winrt'). Only takes effect together with --js-bindings on a fresh init; ignored on re-init when winapp.yaml already declares jsBindings:. | (none) | | `--no-gitignore` | Don't update .gitignore file | (none) | | `--setup-sdks` | SDK installation mode: 'stable' (default), 'preview', 'experimental', or 'none' (skip SDK installation) | (none) | | `--use-defaults` | Do not prompt, and use default of all prompts | (none) | @@ -255,42 +254,6 @@ Creates packaged layout, registers the Application, and launches the packaged ap | `--unregister-on-exit` | Unregister the development package after the application exits. Only removes packages registered in development mode. | (none) | | `--with-alias` | Launch the app using its execution alias instead of AUMID activation. The app runs in the current terminal with inherited stdin/stdout/stderr. Requires a uap5:ExecutionAlias in the manifest. Use "winapp manifest add-alias" to add an execution alias to the manifest. | (none) | -### `winapp node jsbindings add` - -Add a jsBindings: block to winapp.yaml and run codegen. Requires winapp.yaml (run 'winapp init' first). Never modifies the packages: section or installs SDK packages — codegen runs against the workspace's already-restored packages. Refuses to clobber an existing jsBindings: block unless --force is passed. Only available when invoked via the @microsoft/winappcli npm package (npx winapp node jsbindings add). - -#### Arguments - -| Argument | Required | Description | -|----------|----------|-------------| -| `` | No | Base/root directory for the winapp workspace (default: current directory) | - -#### Options - -| Option | Description | Default | -|--------|-------------|---------| -| `--ai` | Generate bindings for the 'ai' slice of the SDK only. For a custom slice that no preset covers, edit winapp.yaml's jsBindings.packages after adding. Known presets: ai. | (none) | -| `--config-dir` | Directory containing winapp.yaml (default: base-directory) | (none) | -| `--force` | Patch an existing jsBindings: block without prompting. Overwrites only output and (when a preset like --ai is supplied) the packages list; all other fields are preserved. Without --force the command refuses to clobber a pre-existing block (interactive: prompts; non-interactive: errors). | (none) | -| `--output` | Output directory for generated JS/TS bindings (relative to workspace, default 'bindings/winrt'). Persisted to winapp.yaml's jsBindings.output field. | (none) | -| `--use-defaults` | Do not prompt. When jsBindings: already exists in winapp.yaml, preserve it and exit 0 (idempotent). Use --force instead if you want the existing block patched non-interactively. | (none) | - -### `winapp node jsbindings generate` - -Re-run dynwinrt-codegen against the existing jsBindings: block in winapp.yaml. Does NOT modify the yaml — for that, use 'node jsbindings add'. Errors if no jsBindings: block is declared. Only available when invoked via the @microsoft/winappcli npm package (npx winapp node jsbindings generate). - -#### Arguments - -| Argument | Required | Description | -|----------|----------|-------------| -| `` | No | Base/root directory for the winapp workspace (default: current directory) | - -#### Options - -| Option | Description | Default | -|--------|-------------|---------| -| `--config-dir` | Directory containing winapp.yaml (default: base-directory) | (none) | - ### `winapp unregister` Unregisters a sideloaded development package. Only removes packages registered in development mode (e.g., via 'winapp run' or 'create-debug-identity'). diff --git a/.github/plugin/skills/winapp-cli/ui-automation/SKILL.md b/.github/plugin/skills/winapp-cli/ui-automation/SKILL.md index 93b2e237..d512ddbb 100644 --- a/.github/plugin/skills/winapp-cli/ui-automation/SKILL.md +++ b/.github/plugin/skills/winapp-cli/ui-automation/SKILL.md @@ -183,7 +183,7 @@ The `--json` envelope for `ui inspect`, `ui get-focused`, `ui search`, and `ui w - `ui search --json` / `ui wait-for --json` may include an `invokableAncestor` field (element-shaped) on each match. - Per-element `id`, `parentSelector`, and `windowHandle` are **removed** — use `selector` as the public handle. -Full schemas with examples: [`references/ui-json-envelope.md`](./references/ui-json-envelope.md). +Full schemas with examples: `references/ui-json-envelope.md`. ## Related skills - `winapp-setup` for adding Windows SDK to your project diff --git a/README.md b/README.md index 005e9d1e..21755540 100644 --- a/README.md +++ b/README.md @@ -226,8 +226,6 @@ See also: [Debugging Guide](./docs/debugging.md) — choosing between `winapp ru **Node.js/Electron Specific:** -- [`node jsbindings add`](./docs/usage.md#node-jsbindings-add) - Add typed JS/TypeScript WinRT bindings to an existing workspace -- [`node jsbindings generate`](./docs/usage.md#node-jsbindings-generate) - Re-run codegen against an existing `jsBindings:` block (no yaml mutation) - [`node create-addon`](./docs/usage.md#node-create-addon) - Generate native C# or C++ addons - [`node add-electron-debug-identity`](./docs/usage.md#node-add-electron-debug-identity) - Add identity to Electron processes - [`node clear-electron-debug-identity`](./docs/usage.md#node-clear-electron-debug-identity) - Remove identity from Electron processes diff --git a/docs/cli-schema.json b/docs/cli-schema.json index 506c67bd..1cfbb2a0 100644 --- a/docs/cli-schema.json +++ b/docs/cli-schema.json @@ -626,7 +626,7 @@ } }, "init": { - "description": "Start here for initializing a Windows app with required setup. Sets up everything needed for Windows app development: creates Package.appxmanifest with default assets, downloads Windows SDK and Windows App SDK packages, and generates projections. When SDK packages are managed (--setup-sdks stable/preview/experimental), also creates winapp.yaml to pin versions for 'restore'/'update'; with --setup-sdks none (e.g., for Rust/Tauri projects that bring their own SDK bindings), no winapp.yaml is created. Interactive by default (use --use-defaults to skip prompts). Use 'restore' instead if you cloned a repo that already has winapp.yaml. Use 'manifest generate' if you only need a manifest, or 'cert generate' if you need a development certificate for code signing.", + "description": "Start here for initializing a Windows app with required setup. Sets up everything needed for Windows app development: creates Package.appxmanifest with default assets, downloads Windows SDK and Windows App SDK packages, and generates projections. When SDK packages are managed (--setup-sdks stable/preview/experimental), also creates winapp.yaml to pin versions for 'restore'/'update'; with --setup-sdks none (e.g., for Rust/Tauri projects that bring their own SDK bindings), no winapp.yaml is created. When invoked via the @microsoft/winappcli npm package (npx winapp init), additionally asks whether to generate C++ projections, JS/TS bindings, or both. Interactive by default (use --use-defaults to skip prompts). Use 'restore' instead if you cloned a repo that already has winapp.yaml. Use 'manifest generate' if you only need a manifest, or 'cert generate' if you need a development certificate for code signing.", "hidden": false, "arguments": { "base-directory": { @@ -683,58 +683,6 @@ "required": false, "recursive": false }, - "--js-bindings": { - "description": "Generate JS/TS bindings via dynwinrt-codegen on top of the standard init flow. Adds a 'jsBindings:' block to winapp.yaml so the binding generator runs as part of init/restore. Only available when invoked via the @microsoft/winappcli npm package (npx winapp init --js-bindings).", - "hidden": false, - "valueType": "System.Boolean", - "hasDefaultValue": true, - "defaultValue": false, - "arity": { - "minimum": 0, - "maximum": 1 - }, - "required": false, - "recursive": false - }, - "--js-bindings-ai": { - "description": "Generate bindings for the 'ai' slice of the SDK. Implies --js-bindings (no need to pass it separately). For a custom slice that no preset covers, edit winapp.yaml and write your own packages: list under jsBindings. Known presets: ai.", - "hidden": false, - "valueType": "System.Boolean", - "hasDefaultValue": true, - "defaultValue": false, - "arity": { - "minimum": 0, - "maximum": 1 - }, - "required": false, - "recursive": false - }, - "--js-bindings-lang": { - "description": "Override the JS bindings language. Currently only 'js' is supported (which emits both .js and .d.ts). Reserved for forward-compat; see --js-bindings-output for activation rules.", - "hidden": false, - "helpName": "js", - "valueType": "System.String", - "hasDefaultValue": false, - "arity": { - "minimum": 1, - "maximum": 1 - }, - "required": false, - "recursive": false - }, - "--js-bindings-output": { - "description": "Override the output directory for generated JS/TS bindings (relative to workspace, default 'bindings/winrt'). Only takes effect together with --js-bindings on a fresh init; ignored on re-init when winapp.yaml already declares jsBindings:.", - "hidden": false, - "helpName": "PATH", - "valueType": "System.String", - "hasDefaultValue": false, - "arity": { - "minimum": 1, - "maximum": 1 - }, - "required": false, - "recursive": false - }, "--no-gitignore": { "description": "Don't update .gitignore file", "hidden": false, @@ -1122,202 +1070,6 @@ } } }, - "node": { - "description": "Node.js / Electron-specific winapp commands. Only available when invoked via the @microsoft/winappcli npm package (npx winapp node ...).", - "hidden": false, - "subcommands": { - "jsbindings": { - "description": "Manage JS/TS WinRT bindings for an existing workspace. 'add' mutates winapp.yaml + runs codegen; 'generate' just runs codegen against the existing yaml. Only available via the @microsoft/winappcli npm package.", - "hidden": false, - "aliases": [ - "js-bindings" - ], - "subcommands": { - "add": { - "description": "Add a jsBindings: block to winapp.yaml and run codegen. Requires winapp.yaml (run 'winapp init' first). Never modifies the packages: section or installs SDK packages — codegen runs against the workspace's already-restored packages. Refuses to clobber an existing jsBindings: block unless --force is passed. Only available when invoked via the @microsoft/winappcli npm package (npx winapp node jsbindings add).", - "hidden": false, - "arguments": { - "base-directory": { - "description": "Base/root directory for the winapp workspace (default: current directory)", - "order": 0, - "hidden": false, - "valueType": "System.IO.DirectoryInfo", - "hasDefaultValue": false, - "arity": { - "minimum": 0, - "maximum": 1 - } - } - }, - "options": { - "--ai": { - "description": "Generate bindings for the 'ai' slice of the SDK only. For a custom slice that no preset covers, edit winapp.yaml's jsBindings.packages after adding. Known presets: ai.", - "hidden": false, - "valueType": "System.Boolean", - "hasDefaultValue": true, - "defaultValue": false, - "arity": { - "minimum": 0, - "maximum": 1 - }, - "required": false, - "recursive": false - }, - "--config-dir": { - "description": "Directory containing winapp.yaml (default: base-directory)", - "hidden": false, - "valueType": "System.IO.DirectoryInfo", - "hasDefaultValue": false, - "arity": { - "minimum": 1, - "maximum": 1 - }, - "required": false, - "recursive": false - }, - "--force": { - "description": "Patch an existing jsBindings: block without prompting. Overwrites only output and (when a preset like --ai is supplied) the packages list; all other fields are preserved. Without --force the command refuses to clobber a pre-existing block (interactive: prompts; non-interactive: errors).", - "hidden": false, - "valueType": "System.Boolean", - "hasDefaultValue": true, - "defaultValue": false, - "arity": { - "minimum": 0, - "maximum": 1 - }, - "required": false, - "recursive": false - }, - "--output": { - "description": "Output directory for generated JS/TS bindings (relative to workspace, default 'bindings/winrt'). Persisted to winapp.yaml's jsBindings.output field.", - "hidden": false, - "helpName": "PATH", - "valueType": "System.String", - "hasDefaultValue": false, - "arity": { - "minimum": 1, - "maximum": 1 - }, - "required": false, - "recursive": false - }, - "--quiet": { - "description": "Suppress progress messages", - "hidden": false, - "aliases": [ - "-q" - ], - "valueType": "System.Boolean", - "hasDefaultValue": true, - "defaultValue": false, - "arity": { - "minimum": 0, - "maximum": 1 - }, - "required": false, - "recursive": false - }, - "--use-defaults": { - "description": "Do not prompt. When jsBindings: already exists in winapp.yaml, preserve it and exit 0 (idempotent). Use --force instead if you want the existing block patched non-interactively.", - "hidden": false, - "aliases": [ - "--no-prompt" - ], - "valueType": "System.Boolean", - "hasDefaultValue": true, - "defaultValue": false, - "arity": { - "minimum": 0, - "maximum": 1 - }, - "required": false, - "recursive": false - }, - "--verbose": { - "description": "Enable verbose output", - "hidden": false, - "aliases": [ - "-v" - ], - "valueType": "System.Boolean", - "hasDefaultValue": true, - "defaultValue": false, - "arity": { - "minimum": 0, - "maximum": 1 - }, - "required": false, - "recursive": false - } - } - }, - "generate": { - "description": "Re-run dynwinrt-codegen against the existing jsBindings: block in winapp.yaml. Does NOT modify the yaml — for that, use 'node jsbindings add'. Errors if no jsBindings: block is declared. Only available when invoked via the @microsoft/winappcli npm package (npx winapp node jsbindings generate).", - "hidden": false, - "arguments": { - "base-directory": { - "description": "Base/root directory for the winapp workspace (default: current directory)", - "order": 0, - "hidden": false, - "valueType": "System.IO.DirectoryInfo", - "hasDefaultValue": false, - "arity": { - "minimum": 0, - "maximum": 1 - } - } - }, - "options": { - "--config-dir": { - "description": "Directory containing winapp.yaml (default: base-directory)", - "hidden": false, - "valueType": "System.IO.DirectoryInfo", - "hasDefaultValue": false, - "arity": { - "minimum": 1, - "maximum": 1 - }, - "required": false, - "recursive": false - }, - "--quiet": { - "description": "Suppress progress messages", - "hidden": false, - "aliases": [ - "-q" - ], - "valueType": "System.Boolean", - "hasDefaultValue": true, - "defaultValue": false, - "arity": { - "minimum": 0, - "maximum": 1 - }, - "required": false, - "recursive": false - }, - "--verbose": { - "description": "Enable verbose output", - "hidden": false, - "aliases": [ - "-v" - ], - "valueType": "System.Boolean", - "hasDefaultValue": true, - "defaultValue": false, - "arity": { - "minimum": 0, - "maximum": 1 - }, - "required": false, - "recursive": false - } - } - } - } - } - } - }, "package": { "description": "Create MSIX installer from your built app. Run after building your app. A manifest (Package.appxmanifest or appxmanifest.xml) is required for packaging - it must be in current working directory, passed as --manifest or be in the input folder. Use --cert devcert.pfx to sign for testing. Example: winapp package ./dist --manifest Package.appxmanifest --cert ./devcert.pfx", "hidden": false, diff --git a/docs/fragments/skills/winapp-cli/frameworks.md b/docs/fragments/skills/winapp-cli/frameworks.md index dfcc1f11..2abe2d53 100644 --- a/docs/fragments/skills/winapp-cli/frameworks.md +++ b/docs/fragments/skills/winapp-cli/frameworks.md @@ -25,25 +25,24 @@ Use the **npm package** (`@Microsoft/WinAppCli`), **not** the standalone CLI. Th - The native winapp CLI binary bundled inside `node_modules` - A Node.js SDK with helpers for creating native C#/C++ addons - Electron-specific commands under `npx winapp node` -- `node jsbindings add` — generates typed JS/TypeScript WinRT wrappers via dynwinrt (no native build required) +- An interactive bindings prompt during `npx winapp init` that offers **C++ projections**, **JS/TS bindings** (typed JS/TypeScript WinRT wrappers via dynwinrt, no native build required), or **Both** Quick start: ```powershell npm install --save-dev @microsoft/winappcli -npx winapp init --use-defaults --js-bindings-ai # init + generate typed AI bindings in bindings/winrt/ -# (or, if you already initialized the workspace:) -npx winapp node jsbindings add --ai # layer JS bindings onto an existing workspace +npx winapp init --use-defaults # init + generate full Windows App SDK JS bindings AND C++ projections (default: Both) +# (interactive: omit --use-defaults to pick C++ / JS / Both at the prompt) npx winapp node create-addon --template cs # create a C# native addon (for what dynwinrt can't drive — see below) npx winapp node add-electron-debug-identity # register identity for debugging ``` -The `--js-bindings*` flags (and the `node jsbindings add` sub-command) are **npm-only** — they require invocation via the `@microsoft/winappcli` npm package because they pull in `@microsoft/dynwinrt-codegen` as a transitive dep. The winget / standalone install will reject these surfaces with a clear error. +JS/TS bindings (the `jsBindings:` block in `winapp.yaml`) are **npm-only** — they require invocation via the `@microsoft/winappcli` npm package because they pull in `@microsoft/dynwinrt-codegen` as a transitive dep. The bindings prompt does not appear when running the standalone winget CLI. #### Choosing between jsBindings and a native addon The decision is almost entirely about the **shape of the API**, not preference. -**Default: if the API is WinRT (ships in a `.winmd`), use `node jsbindings add`.** That covers nearly everything an Electron app actually calls on Windows — `Microsoft.Windows.*` (Notifications, FilePickers, Sensors, Storage, AI inference like `TextRecognizer` / `LanguageModel`), most of `Windows.*`, and all of `Microsoft.WindowsAppSDK.AI`. dynwinrt's [own scope statement](https://github.com/microsoft/dynwinrt#scope) sums it up as "non-UI WinRT APIs ... headless services from the Windows SDK and WinAppSDK". +**Default: if the API is WinRT (ships in a `.winmd`), pick JS bindings at the `npx winapp init` prompt.** That covers nearly everything an Electron app actually calls on Windows — `Microsoft.Windows.*` (Notifications, FilePickers, Sensors, Storage, AI inference like `TextRecognizer` / `LanguageModel`), most of `Windows.*`, and all of `Microsoft.WindowsAppSDK.AI`. dynwinrt's [own scope statement](https://github.com/microsoft/dynwinrt#scope) sums it up as "non-UI WinRT APIs ... headless services from the Windows SDK and WinAppSDK". **Fall back to `node create-addon` when one of these is true:** @@ -56,7 +55,7 @@ The decision is almost entirely about the **shape of the API**, not preference. It's normal to mix both in one app: jsBindings for the non-UI WinRT surface, a small C# or C++ addon for the one or two Win32 / non-WinRT calls that don't fit. Additional Electron guides: -- [JS bindings reference](https://github.com/microsoft/WinAppCli/blob/main/docs/js-bindings.md) — full `jsBindings:` yaml schema, presets, per-package classification, lockfile +- [JS bindings reference](https://github.com/microsoft/WinAppCli/blob/main/docs/js-bindings.md) — full `jsBindings:` yaml schema, per-package classification, lockfile - [Packaging guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/packaging.md) - [C++ notification addon guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/cpp-notification-addon.md) - [WinML addon guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/winml-addon.md) diff --git a/docs/fragments/skills/winapp-cli/setup.md b/docs/fragments/skills/winapp-cli/setup.md index f10042e9..3f6ecf26 100644 --- a/docs/fragments/skills/winapp-cli/setup.md +++ b/docs/fragments/skills/winapp-cli/setup.md @@ -49,19 +49,22 @@ winapp init --use-defaults --setup-sdks preview ### Add JS/TS bindings for Node / Electron apps (npm only) -When invoked via the `@microsoft/winappcli` npm package, you can generate -typed JS/TS bindings for WinRT APIs alongside the standard init: +When invoked via the `@microsoft/winappcli` npm package (i.e. `npx winapp init` +inside a Node / Electron project), `init` adds an interactive **bindings prompt** +that asks whether to generate **C++ projections**, **JS/TS bindings**, or **Both**. +Picking JS or Both wires a default `jsBindings:` block into `winapp.yaml` (covering +the full Windows App SDK) and runs codegen as part of init: ```powershell -# Initialize with the AI slice of the SDK pre-wired. -npx winapp init --use-defaults --js-bindings-ai +# Interactive — prompted to pick C++ / JS / Both. +npx winapp init -# Or layer bindings onto an already-initialized workspace. -npx winapp node jsbindings add --ai +# Non-interactive — auto-picks "Both" (C++ projections + JS/TS bindings). +npx winapp init --use-defaults # After editing winapp.yaml jsBindings: by hand (or pulling a teammate's # winapp.yaml), regenerate bindings without re-prompting: -npx winapp node jsbindings generate +npx winapp restore ``` Generated files land under `bindings/winrt/` and `@microsoft/dynwinrt` is diff --git a/docs/guides/electron/index.md b/docs/guides/electron/index.md index 7c2d4d0d..92c2a0a1 100644 --- a/docs/guides/electron/index.md +++ b/docs/guides/electron/index.md @@ -40,7 +40,7 @@ Next, choose how to call Windows APIs from your Electron app: #### Option A: [JS/TypeScript bindings via dynwinrt](../../js-bindings.md) ✨ *new* -The simplest path — typed JS/TypeScript wrappers generated from `.winmd` metadata, no native build step required from your Electron project. One command (`npx winapp node jsbindings add --ai`) drops a `bindings/winrt/` directory next to your sources; you `import { ChatClient } from './bindings/winrt'` and call WinRT directly. Bindings are typed at compile time but use `dynwinrt`'s libffi runtime to invoke methods at runtime, so no MSBuild / `node-gyp` step is involved. +The simplest path — typed JS/TypeScript wrappers generated from `.winmd` metadata, no native build step required from your Electron project. When you run `npx winapp init`, the interactive bindings prompt offers **C++**, **JS/TS**, or **Both**; pick JS or Both and `bindings/winrt/` is dropped next to your sources. You `import { ChatClient } from './bindings/winrt'` and call WinRT directly. Bindings are typed at compile time but use `dynwinrt`'s libffi runtime to invoke methods at runtime, so no MSBuild / `node-gyp` step is involved. [Add JS bindings →](../../js-bindings.md) diff --git a/docs/guides/electron/jsbindings.md b/docs/guides/electron/jsbindings.md index fab5f7c9..f7f5c940 100644 --- a/docs/guides/electron/jsbindings.md +++ b/docs/guides/electron/jsbindings.md @@ -9,41 +9,55 @@ This guide shows you how to call modern Windows Runtime (WinRT) APIs directly fr Before starting this guide, make sure you've: - Completed the [development environment setup](setup.md) -- Used `winapp` via `npx` (i.e., the `@microsoft/winappcli` npm package) — JS bindings are gated to npm-invoked `winapp` because the generator (`@microsoft/dynwinrt-codegen`) and runtime (`@microsoft/dynwinrt`) ship as npm dependencies. A winget / standalone install of `winapp.exe` will reject `--js-bindings*` and `node jsbindings add` with a clear error. +- Used `winapp` via `npx` (i.e., the `@microsoft/winappcli` npm package) — JS bindings are gated to npm-invoked `winapp` because the generator (`@microsoft/dynwinrt-codegen`) and runtime (`@microsoft/dynwinrt`) ship as npm dependencies. The standalone winget / installer build does not surface the bindings prompt and does not generate JS bindings. ## Step 1: Add JS bindings to your project You have two paths depending on whether your Electron app already has a `winapp.yaml`. -### Path A — Fresh project (init with bindings) +### Path A — Fresh project (init with bindings prompt) -If you're setting up `winapp` for the first time, ask `init` to wire bindings in the same step. The `--js-bindings-ai` preset narrows generation to the Windows AI surface — the [`Microsoft.WindowsAppSDK.AI`](https://www.nuget.org/packages/Microsoft.WindowsAppSDK.AI) NuGet package, which projects the `Microsoft.Windows.AI.*` namespaces: +When you run `npx winapp init` for the first time, the CLI shows an interactive **bindings prompt** asking whether to generate **C++ projections**, **JS/TS bindings**, or **Both**: ```bash -npx winapp init --use-defaults --js-bindings-ai -npm install +npx winapp init +# > Bindings to generate: +# C++ projections +# JS/TS bindings +# ❯ Both (default) ``` -`init` installs the AI NuGet package, writes a `winapp.yaml` with a `jsBindings:` block, and runs the codegen. `npm install` picks up the `@microsoft/dynwinrt` runtime dependency that `init` injected into your `package.json`. +Pick **JS/TS bindings** for a pure Node/Electron project (skips cppwinrt headers/libs — saves ~130 MB and ~20 s), or **Both** to keep both surfaces available. `init` then installs the WinAppSDK packages, writes a `winapp.yaml` containing a default `jsBindings:` block (covering the full Windows App SDK), and runs the codegen. -For the full WinAppSDK surface (every namespace), drop the preset: +For a scripted / CI install, `--use-defaults` auto-picks **Both** without prompting: ```bash -npx winapp init --js-bindings +npx winapp init --use-defaults +npm install # picks up the @microsoft/dynwinrt runtime dep that init injected ``` ### Path B — Existing project (layer bindings on) -If `winapp.yaml` already exists and you don't want to re-run the full `init` pipeline, use the layered `node jsbindings add` sub-command. It edits **only** the `jsBindings:` block, never re-restores SDK packages, and runs codegen against your already-restored winmds: +If `winapp.yaml` already exists and you want to add JS bindings, edit the yaml and add a `jsBindings:` block. The empty form covers the full Windows App SDK: -```bash -npx winapp node jsbindings add --ai +```yaml +# winapp.yaml +jsBindings: {} +``` + +If you want JS bindings without cppwinrt projections, also set `cppProjections: false` at the top level: + +```yaml +# winapp.yaml +cppProjections: false +jsBindings: {} ``` -If `jsBindings:` already exists and you want to overwrite, pass `--force`: +Then run `restore` — it will pick up the new block, run codegen, and inject the `@microsoft/dynwinrt` runtime dep into `package.json`: ```bash -npx winapp node jsbindings add --ai --force +npx winapp restore +npm install ``` ### What you get @@ -61,10 +75,10 @@ bindings/winrt/ └── … # one pair of files per emitted class ``` -To put them somewhere else, pass `--js-bindings-output PATH` (for `init`) or `--output PATH` (for `node jsbindings add`). +To put them somewhere else, set `jsBindings.output:` in `winapp.yaml` (e.g. `output: src/generated/winrt`) and re-run `restore`. > [!NOTE] -> If you need to slice generation by NuGet package, add your own `.winmd`, or cherry-pick a few classes from a giant vendor SDK, see the recipes in [JS / TypeScript bindings for WinRT](../../js-bindings.md). This guide sticks to the simplest preset-driven flow. +> If you need to slice generation by NuGet package, add your own `.winmd`, or cherry-pick a few classes from a giant vendor SDK, see the recipes in [JS / TypeScript bindings for WinRT](../../js-bindings.md). This guide sticks to the simplest default-scope flow. ## Step 2: Call a WinRT API from your Electron code @@ -138,25 +152,19 @@ The first call to a WinRT method imported from `bindings/winrt/` will load `@mic The generated `bindings/winrt/` files are committed-or-gitignored at your discretion (treat them like `package-lock.json` — generated, but stable enough to commit if you want diff visibility). Regenerate whenever: - You bump a WinAppSDK / WinRT package version in `winapp.yaml` -- You add or remove entries in `additionalWinmds:` / `extraTypes:` +- You add or remove entries in `jsBindings.packages:` / `additionalWinmds:` / `extraTypes:` - The codegen itself is upgraded (`npm update @microsoft/dynwinrt-codegen`) -To regenerate without changing the `jsBindings:` configuration: - -```bash -npx winapp restore # regenerates bindings as part of the standard restore step -``` - -Or, to replace the block and regenerate from scratch: +In all cases, re-run `restore` — it picks up the current `winapp.yaml` (no yaml mutation) and re-runs codegen: ```bash -npx winapp node jsbindings add --ai --force +npx winapp restore ``` ## Troubleshooting **`Cannot find module './bindings/winrt'`** -The generator hasn't produced output yet. Re-run `npx winapp restore` (or `node jsbindings add`) and verify `bindings/winrt/index.js` exists. +The generator hasn't produced output yet. Re-run `npx winapp restore` and verify `bindings/winrt/index.js` exists. **`MissingMethodException` / `Type not registered`** A class your code imports is in a `.winmd` that isn't on the codegen's input. Check the `packages:` list (or `additionalWinmds:`) in `winapp.yaml` — empty/omitted `jsBindings.packages` means "all installed packages participate", but if you've curated the list make sure the relevant package is there. @@ -169,7 +177,7 @@ Make sure `@microsoft/dynwinrt` is in your runtime `dependencies` (not just `dev ## Next steps -- **Reference** — [JS / TypeScript bindings for WinRT (`jsBindings`)](../../js-bindings.md) for the full `winapp.yaml` schema, every CLI flag, and advanced recipes (slice by package, cherry-pick types, ship a vendor `.winmd`). -- **CLI** — [`npx winapp node jsbindings add` reference](../../usage.md#node-jsbindings-add) and [`npx winapp init` reference](../../usage.md#init). +- **Reference** — [JS / TypeScript bindings for WinRT (`jsBindings`)](../../js-bindings.md) for the full `winapp.yaml` schema and advanced recipes (slice by package, cherry-pick types, ship a vendor `.winmd`). +- **CLI** — [`npx winapp init` reference](../../usage.md#init) and [`npx winapp restore` reference](../../usage.md#restore). - **Runtime** — [`@microsoft/dynwinrt` on GitHub](https://github.com/microsoft/dynwinrt) for the libffi-based runtime that powers the generated bindings. - **Package & ship** — [Packaging Your App](packaging.md) once you're ready to produce an MSIX for distribution. diff --git a/docs/js-bindings.md b/docs/js-bindings.md index 115e3792..1496fc01 100644 --- a/docs/js-bindings.md +++ b/docs/js-bindings.md @@ -1,37 +1,50 @@ # JS / TypeScript bindings for WinRT (`jsBindings` feature) -`winapp` can generate typed JavaScript + TypeScript wrappers for Windows Runtime APIs as part of the standard `init` / `restore` flow, or layered onto an existing workspace via the `node jsbindings add` sub-command. The generator runs on top of [dynwinrt](https://github.com/microsoft/dynwinrt) — a runtime FFI bridge that calls WinRT methods via `.winmd` metadata, so the produced bindings are **typed at compile time** but call WinRT **dynamically at runtime** (no native build step required from your project). +`winapp` can generate typed JavaScript + TypeScript wrappers for Windows Runtime APIs as part of the standard `init` / `restore` flow. The generator runs on top of [dynwinrt](https://github.com/microsoft/dynwinrt) — a runtime FFI bridge that calls WinRT methods via `.winmd` metadata, so the produced bindings are **typed at compile time** but call WinRT **dynamically at runtime** (no native build step required from your project). -This document covers the user-facing CLI (both `init --js-bindings*` and `node jsbindings add`), the `winapp.yaml` schema, recipes for the common scenarios, and a brief description of what happens under the hood. It reflects the current state of the feature including the v2.0 codegen-owned input refactor. +This document covers the user-facing CLI flow, the `winapp.yaml` schema, recipes for common scenarios, and a brief description of what happens under the hood. -> **Availability** — the `--js-bindings*` flags and the `node jsbindings add` sub-command are gated behind invocation via the `@microsoft/winappcli` npm package (i.e. `npx winapp …`). Running `winapp` from a winget / standalone install will reject these surfaces with a clear error message, because the JS-binding generator (`@microsoft/dynwinrt-codegen`) and the runtime (`@microsoft/dynwinrt`) ship as npm dependencies. +> **Availability** — JS/TS bindings are gated behind invocation via the `@microsoft/winappcli` npm package (i.e. `npx winapp …`). The interactive bindings prompt on `winapp init` only appears when invoked through the npm shim, because the binding generator (`@microsoft/dynwinrt-codegen`) and the runtime (`@microsoft/dynwinrt`) ship as npm dependencies. The standalone winget / installer build does not surface the prompt. --- ## Quick start -The fastest path to "I want to call the WinAppSDK AI APIs from my Node app": +The fastest path to "I want to call WinAppSDK / Windows Runtime APIs from my Node app": ```bash npm i -D @microsoft/winappcli -npx winapp init --use-defaults --js-bindings-ai -npm install # picks up the @microsoft/dynwinrt runtime dep that init injected +npx winapp init --use-defaults # auto-picks "Both" (C++ projections + JS bindings) +npm install # picks up the @microsoft/dynwinrt runtime dep that init injected ``` -That gives you `bindings/winrt/*.js` + `*.d.ts` for the WinAppSDK AI surface, ready to import: +That gives you `bindings/winrt/*.js` + `*.d.ts` for the full Windows App SDK surface, ready to import: ```ts import { LanguageModel } from './bindings/winrt/Microsoft.Windows.AI.Generative.LanguageModel'; const model = await LanguageModel.createAsync(); ``` -Already have a `winapp.yaml` and just want to add bindings on top? +Want the interactive prompt instead? Omit `--use-defaults`: ```bash -npx winapp node jsbindings add --ai +npx winapp init +# > Bindings to generate: +# C++ projections +# JS/TS bindings +# ❯ Both (default) ``` -Same end-state, but layered onto an existing workspace — `packages:` is left untouched and only the `jsBindings:` block is added. +Already have a `winapp.yaml` and just want to add bindings on top? Edit the yaml to add an empty `jsBindings: {}` block (or a scoped one — see [workflow #3](#3-slice-generation-by-nuget-package)) and run `npx winapp restore`: + +```yaml +# winapp.yaml +jsBindings: {} # full Windows App SDK surface +``` + +```bash +npx winapp restore +``` --- @@ -39,40 +52,47 @@ Same end-state, but layered onto an existing workspace — `packages:` is left u > The yaml snippets below show only the fields each workflow touches. For the complete `jsBindings:` schema (every field, default values, type, composition rules), see [`winapp.yaml` — `jsBindings:` block](#winappyaml--jsbindings-block). -### 1. Generate bindings for the WinAppSDK AI APIs +### 1. Generate bindings for the full WinAppSDK surface -```bash -npx winapp init --use-defaults --js-bindings-ai +```yaml +# winapp.yaml +jsBindings: {} ``` -The `ai` preset narrows binding generation to the `Microsoft.WindowsAppSDK.AI` NuGet package. All other installed packages are still restored for the C# / native build, just not turned into JS bindings. +The empty block accepts the defaults: `lang: js`, `output: bindings/winrt`, and `packages: []` which means **every installed package's `.winmd` files participate**. Convenient for exploration; for a shipping app you may want to narrow `packages:` to just the APIs you actually call. -### 2. Generate bindings for the full WinAppSDK surface +> XAML namespaces (`Microsoft.UI.Xaml.*`, `Windows.UI.Xaml.*`) are out of scope for dynwinrt — the codegen itself classifies them automatically as resolution-only refs, so no JS gets emitted for them regardless of which packages are in scope. -```bash -npx winapp init --js-bindings -``` +### 2. Skip C++ projections (JS-only project) -Without a preset, every installed package's `.winmd` files participate in binding generation (plus any winmds added via `additionalWinmds:`). Convenient for exploration; for a shipping app prefer the `--js-bindings-ai` preset or a hand-curated `packages:` list. +If your app is pure Node/Electron and you don't need cppwinrt headers, the bindings prompt's **JS/TS bindings** option (or `cppProjections: false` in `winapp.yaml`) skips the ~130 MB / ~20 s cppwinrt step entirely: -> XAML namespaces (`Microsoft.UI.Xaml.*`, `Windows.UI.Xaml.*`) are out of scope for dynwinrt — the codegen itself classifies them automatically as resolution-only refs, so no JS gets emitted for them regardless of which packages are in scope. +```yaml +# winapp.yaml +cppProjections: false +jsBindings: {} +``` + +```bash +npx winapp restore +``` ### 3. Slice generation by NuGet package -When the `ai` preset is too narrow but you also don't want bindings for every installed package, edit `winapp.yaml` and list the NuGet package IDs you actually want bindings for: +When you don't want bindings for every installed package, list the NuGet package IDs you actually want bindings for: ```yaml # winapp.yaml jsBindings: output: bindings/winrt packages: - - Microsoft.WindowsAppSDK.AI # AI APIs + - Microsoft.WindowsAppSDK.AI # AI APIs only - Microsoft.WindowsAppSDK # full WinAppSDK on top ``` -Each entry must match a NuGet package ID present under your top-level `packages:` block. Empty / omitted means "all installed packages participate" (the v2 default). +Each entry must match a NuGet package ID present under your top-level `packages:` block. Empty / omitted means "all installed packages participate" (the default). -> v2.0 removed the older namespace-prefix slicing (`includeNamespacePrefixes:` / `excludeNamespacePrefixes:`). Slicing now happens at the package level — coarser, but matches how WinRT metadata is actually shipped. +> Earlier versions supported namespace-prefix slicing (`includeNamespacePrefixes:` / `excludeNamespacePrefixes:`). Slicing now happens at the package level — coarser, but matches how WinRT metadata is actually shipped. ### 4. Add your own / a vendor `.winmd` @@ -107,79 +127,20 @@ This is the right pattern when the vendor ships a 200 MB winmd and you only want ### 6. Override the output directory -```bash -npx winapp init --js-bindings --js-bindings-output src/generated/winrt -``` - -Or via `winapp.yaml`: - ```yaml jsBindings: output: src/generated/winrt ``` -For `node jsbindings add`, use `--output` (the sub-command name already scopes it): +### 7. Re-run codegen after editing `winapp.yaml` -```bash -npx winapp node jsbindings add --ai --output src/generated/winrt -``` - -### 7. Re-init: opt into jsBindings on an existing workspace - -If you ran `init` without bindings and later want them, prefer the layered `node jsbindings add` flow — it touches **only** the `jsBindings:` block and runs codegen against your already-restored packages, skipping the SDK installation steps entirely: +Any time you edit the `jsBindings:` block (add a package, swap to a different scope, add an `extraTypes:` entry), re-run: ```bash -npx winapp node jsbindings add --ai # add the AI preset -npx winapp node jsbindings add # add the full surface (no preset) +npx winapp restore ``` -If a `jsBindings:` block already exists, the command refuses by default to avoid clobbering hand edits. Pass `--force` to overwrite without prompting: - -```bash -npx winapp node jsbindings add --ai --force -``` - -Re-running `winapp init --js-bindings` on an existing workspace is also supported (older flow), but it will go through the full restore pipeline; `node jsbindings add` is the recommended way to add bindings to an already-initialized project. - ---- - -## CLI reference - -### `winapp init` — parent flag - -| Flag | Type | Description | -|------|------|-------------| -| `--js-bindings` | bool | Enable jsBindings codegen as part of init/restore. Adds a `jsBindings:` block to `winapp.yaml`. Required to activate any of the sub-options below — except the alias flags, which imply it. | - -### `winapp init` — sub-options (effective only when `--js-bindings` is active) - -| Flag | Type | Default | Description | -|------|------|---------|-------------| -| `--js-bindings-output PATH` | string | `bindings/winrt` | Override the output directory (relative to workspace root). | -| `--js-bindings-lang js` | string | `js` | Target language. `js` emits both `.js` + `.d.ts`. Reserved for forward-compat (`py` exists in `dynwinrt-codegen` but is not yet wired through here). | - -### `winapp init` — preset alias flags (auto-generated, one per preset) - -Each entry in [the preset table](#presets) gets a corresponding bool flag. Alias flags **imply `--js-bindings`** — you don't need to type the parent flag. - -| Flag | Effect | -|------|--------| -| `--js-bindings-ai` | Enable jsBindings + write the `ai` preset's package IDs to `packages:` | - -> Today only the `ai` preset ships. The CLI auto-registers one alias flag per entry in `JsBindingsPresets.KnownPresets`, so adding a future preset is a one-line change with no CLI plumbing. - -### `winapp node jsbindings add` — sub-command - -Layered onto an already-initialized workspace. Requires an existing `winapp.yaml`; never installs SDK packages or rewrites the top-level `packages:` block. The job is to add (or replace, with `--force`) the `jsBindings:` block and run codegen against the workspace's restored packages. - -| Flag | Type | Default | Description | -|------|------|---------|-------------| -| `--config-dir PATH` | path | current dir | Directory containing `winapp.yaml`. | -| `--output PATH` | string | `bindings/winrt` | Output directory for generated `.js` + `.d.ts`. Persisted to `jsBindings.output`. | -| `--force` | bool | `false` | Replace an existing `jsBindings:` block without prompting. | -| `--ai` | bool | `false` | Generate bindings for the `ai` preset only (writes its package IDs to `packages:`). One auto-registered flag per entry in `JsBindingsPresets.KnownPresets`. | - -The first positional argument is the workspace base directory (defaults to the current directory). +`restore` reads the existing yaml without modifying it, re-discovers winmds, and re-runs codegen — the output directory is replaced atomically (stage-then-swap; previous bindings are preserved on codegen failure). --- @@ -194,6 +155,11 @@ packages: - name: Microsoft.WindowsAppSDK.AI version: 0.4.250712-experimental2 +# Skip cppwinrt headers/libs/runtimes/projection generation. Defaults to true +# (C++ projections enabled). Set to false for pure Node/Electron projects that +# only consume JS bindings. +cppProjections: false + jsBindings: # Target language — currently only 'js' (emits both .js and .d.ts). # 'py' is supported in the underlying codegen but not yet exposed here. @@ -233,7 +199,7 @@ jsBindings: - Lens - Sensor - # ── Per-package classification overrides (v2.3+) ────────────────────── + # ── Per-package classification overrides ────────────────────────────── # Layered on top of the built-in default policy (WinUI = skip, # InteractiveExperiences = ref-only). Useful when MS introduces a new XAML # package or you want to force-emit a normally-denylisted one. @@ -256,15 +222,16 @@ jsBindings: | Field | Default | Type | |-------|---------|------| -| `lang` | `js` | string | -| `output` | `bindings/winrt` | string | -| `packages` | `[]` (= all installed packages) | list of NuGet IDs | -| `additionalWinmds` | `[]` | list of paths | -| `additionalRefs` | `[]` | list of paths | -| `extraTypes` | `[]` | list of `{namespace, classes[]}` | -| `skipPackages` | `[]` | list of NuGet IDs | -| `refOnlyPackages` | `[]` | list of NuGet IDs | -| `emitPackages` | `[]` | list of NuGet IDs | +| `cppProjections` (top-level) | `true` | bool | +| `jsBindings.lang` | `js` | string | +| `jsBindings.output` | `bindings/winrt` | string | +| `jsBindings.packages` | `[]` (= all installed packages) | list of NuGet IDs | +| `jsBindings.additionalWinmds` | `[]` | list of paths | +| `jsBindings.additionalRefs` | `[]` | list of paths | +| `jsBindings.extraTypes` | `[]` | list of `{namespace, classes[]}` | +| `jsBindings.skipPackages` | `[]` | list of NuGet IDs | +| `jsBindings.refOnlyPackages` | `[]` | list of NuGet IDs | +| `jsBindings.emitPackages` | `[]` | list of NuGet IDs | ### Composition rules (when multiple lists overlap) @@ -278,21 +245,9 @@ The codegen applies these rules in order: --- -## Presets - -Presets are named bundles of NuGet **package IDs** that get written into the `jsBindings.packages:` list. Today only one preset ships — `ai` — because that's the use case this feature was built for: a one-flag path to the WinAppSDK AI APIs. For anything else, edit `winapp.yaml` directly (see [workflow #3](#3-slice-generation-by-nuget-package)). - -| Preset | `init` flag | `node jsbindings add` flag | Package IDs | Notes | -|--------|------------|------------------------|-------------|-------| -| `ai` | `--js-bindings-ai` | `--ai` | `Microsoft.WindowsAppSDK.AI` | Single-package preset; the codegen handles foundation namespaces (`Microsoft.Foundation`, `Windows.*`) automatically as refs. | - -To add a new preset, edit `JsBindingsPresets.KnownPresets` in `WinApp.Cli/Services/JsBindingsPresets.cs`. Both the `init` alias flag and the matching `node jsbindings add` flag are auto-registered from this dictionary at startup — no other code changes required. - ---- - ## Runtime dependency injection -When `init --js-bindings*` (or `node jsbindings add`) runs for the first time on a workspace, the CLI: +When `init` (or `restore`) runs the JS-bindings step on a workspace, the CLI: 1. Detects your project's package manager from the `packageManager:` field in `package.json`, then falls back to lockfile sniffing (`pnpm-lock.yaml` → pnpm, `yarn.lock` → yarn, `bun.lockb` → bun, `package-lock.json` → npm). Defaults to **npm** if no signal exists. 2. Adds `@microsoft/dynwinrt` to your `package.json` `dependencies` (production dep, NOT devDep) — your generated bindings `import` from it at module load, so it must ship in your installed app. @@ -310,11 +265,11 @@ Supported package managers: **npm, pnpm, yarn, bun**. ┌─────────────────────┐ │ winapp.yaml │ — packages: + jsBindings: blocks └──────────┬──────────┘ - │ (init / restore / node jsbindings add) + │ (init / restore) ▼ ┌──────────────────────────────────────────┐ │ WorkspaceSetupService │ - │ • restore NuGet packages (init/restore) │ + │ • restore NuGet packages │ │ • discover .winmd files in installed │ │ packages, scoped by │ │ jsBindings.packages if set │ @@ -367,60 +322,28 @@ This split happens in `JsBindingsPresets.PartitionByPackageCategory`. Skipped wi ### The `.dynwinrt-managed` marker and `winmds.lock.json` -After a successful generation winapp writes `/.dynwinrt-managed` into the output directory. Its presence is the **only** signal winapp uses to know that a non-empty output directory is safe to wipe before the next codegen run. **Never delete this file by hand**, and never put files you care about under the codegen output directory — the next `restore` / `node jsbindings add` will wipe anything other than the marker. (If the directory is non-empty and the marker is missing, winapp aborts rather than risk overwriting hand-written code.) +After a successful generation winapp writes `/.dynwinrt-managed` into the output directory. Its presence is the **only** signal winapp uses to know that a non-empty output directory is safe to wipe before the next codegen run. **Never delete this file by hand**, and never put files you care about under the codegen output directory — the next `restore` will wipe anything other than the marker. (If the directory is non-empty and the marker is missing, winapp aborts rather than risk overwriting hand-written code.) -In addition, `winapp restore` writes `.winapp/winmds.lock.json` — a human-readable snapshot of every NuGet package the restore resolved, with its version, the per-package winmd discovery results, and the `JsBindingsPresets` category (`emit` / `refOnly` / `skip`). The lockfile is purely an **optimization + audit trail**: +In addition, `winapp restore` writes `.winapp/winmds.lock.json` — a human-readable snapshot of every NuGet package the restore resolved, with its version, the per-package winmd discovery results, and the `JsBindingsPresets` category (`emit` / `refOnly` / `skip`). The lockfile is purely a diagnostic artifact: -- `winapp node jsbindings add` reads it to skip the NuGet `.nuspec` HTTP roundtrip + cache re-glob (typically reduces `node jsbindings add` from ~3s to ~200ms in offline / poor-network conditions). -- When the lockfile is missing or its schema doesn't match, `node jsbindings add` transparently falls back to live discovery — no functional dependency on the file. - Useful to share when reporting bugs: it tells the maintainer exactly what got resolved without us needing to repro your NuGet feed setup. - -**Staleness checks** (v2.3): the lockfile records a SHA-256 of the top-level `packages:` block, and `node jsbindings add` rejects the fast path when: - -1. **yaml drift** — current `packages:` hash differs from the one recorded → user edited yaml since restore. -2. **stale paths** — any winmd path recorded in the lockfile no longer exists on disk → NuGet cache was cleared since restore. - -In both cases `node jsbindings add` falls back to live discovery and tells the user to consider re-running `winapp restore` (which rewrites the lockfile). The fallback path also re-runs the per-package classification (`skipPackages` / `refOnlyPackages` / `emitPackages` overrides take effect immediately — no restore required). +- Records a SHA-256 of the top-level `packages:` block so you can spot yaml drift between restore runs. **Write atomicity**: lockfile writes go through a per-call `.tmp.` sibling that's renamed into place on completion, so concurrent readers always see a consistent (old or new) file, never a torn write. -This contract is what lets the codegen own all metadata-classification logic (refs vs bulk, `Windows.*` defaults, etc.) without winapp having to maintain a parallel C# implementation of the same rules. - --- ## Troubleshooting | Symptom | Cause / fix | |---------|-------------| -| `Error: --js-bindings requires the @microsoft/winappcli npm package` | You ran `winapp` from a winget / standalone install. JS-binding codegen ships as an npm transitive dep — install via `npm i -D @microsoft/winappcli` and call as `npx winapp …`. | +| `winapp init` doesn't show a bindings prompt | You ran the standalone `winapp` (winget / installer). JS bindings ship as an npm-only feature. Install via `npm i -D @microsoft/winappcli` and call as `npx winapp init` to get the prompt. | | `bindings/winrt/` is empty after restore | Most likely your `packages:` slice is too narrow, or matches no installed package. Check the debug log (`-v debug`) for the `winmd partition: emit=… ref-only=… skipped=…` line to see what got passed to the codegen. | | Cannot find a class you expect | The codegen auto-classifies `Windows.*` (and similar foundation namespaces) as refs and does not bulk-emit them. Use `extraTypes:` to pull individual classes out: `{ namespace: 'Windows.Foundation', classes: ['Uri'] }`. | | `winapp` refuses to write into the output directory | The output directory is non-empty and lacks a `.dynwinrt-managed` marker — winapp won't wipe it because it might contain hand-written code. Either point `output:` somewhere else, or delete the directory yourself if you're sure. | -| Imports from `@microsoft/dynwinrt` fail at app runtime | Make sure you ran your package manager's install command after `init` / `node jsbindings add` (so the auto-injected production dep actually downloads). The CLI prints the right command for your PM in the output. | +| Imports from `@microsoft/dynwinrt` fail at app runtime | Make sure you ran your package manager's install command after `init` / `restore` (so the auto-injected production dep actually downloads). The CLI prints the right command for your PM in the output. | | Vendor winmd not found | `additionalWinmds:` / `additionalRefs:` paths are workspace-relative or absolute. Missing files print a warning and are skipped (so a stale entry doesn't break a working restore) — re-check the path. | -| `--js-bindings-output / --js-bindings-lang have no effect without --js-bindings; ignoring.` | You passed a sub-option without `--js-bindings`. Either add `--js-bindings`, or use a `--js-bindings-{preset}` alias flag (which implies it). | -| `node jsbindings add` errors with "jsBindings: already present" | Pass `--force` to replace the existing block without prompting; without it the command refuses to clobber hand-edited config. | - ---- - -## Changelog (feature evolution) - -The feature has shipped in incremental waves; user-visible additions: - -| Version | Headline addition | -|---------|-------------------| -| **v1.0** | Manifest-driven codegen; `init --js-bindings` parent flag; `bindings.manifest.json` written under `.winapp/codegen/`. | -| **v1.1** | XAML namespaces (`Microsoft.UI.Xaml`, `Windows.UI.Xaml`) excluded by default — out of scope for dynwinrt. | -| **v1.2** | `@microsoft/dynwinrt` auto-injected as a production dep in user `package.json`; PM-aware install hint (npm / pnpm / yarn / bun). | -| **v1.4** | `--js-bindings-output` / `--js-bindings-lang` CLI flags; `additionalWinmds:` and `includeNamespacePrefixes:` yaml fields; presets (ai / webview / widgets / appnotifications); re-init UX. | -| **v1.5** | `additionalRefs:` yaml field — load winmds for resolution only, pair with `extraTypes:` to cherry-pick classes from large vendor SDKs without bulk-emitting. | -| **v1.6** | `--js-bindings-{preset}` shorthand alias flags; imply `--js-bindings`; auto-generated from the `KnownPresets` dictionary so adding a preset auto-exposes a flag. **Removed** the now-redundant `--js-bindings-only` flag — the alias flags fully supersede it. | -| **v1.7** | Trimmed shipped presets down to **`ai` only** (the actual goal of this feature: easy on-ramp to WinAppSDK AI APIs). The `webview` / `widgets` / `appnotifications` presets were removed. The dictionary + auto-alias machinery is preserved so a future curated AI sub-slice can be added with one line. (At the time, users wanting those namespaces were directed to write `includeNamespacePrefixes:` directly — that field has since been removed in v2.0; use `jsBindings.packages:` to slice by NuGet package, or `additionalWinmds:` to hand-pick winmd files.) | -| **v1.8** | New `winapp node jsbindings add` sub-command — layered, non-destructive way to add bindings to an existing workspace without going through the full restore pipeline. Auto-registers one `--` flag per entry in `KnownPresets` (e.g. `--ai`). `--force` to replace an existing block, `--output PATH` to override the output dir. | -| **v2.0** | Codegen-owned input refactor. Replaced the JSON manifest (`.winapp/codegen/bindings.manifest.json`) with direct command-line passing of winmd paths to the codegen (`--winmd "p1;p2;..."` / `--ref "r1;r2;..."`). Removed `excludeNamespacePrefixes:` / `includeNamespacePrefixes:` / `importName:` from `winapp.yaml` — `Windows.*` / XAML classification now happens entirely inside the codegen, and slicing happens at the **NuGet package** level via the new `packages:` field instead of namespace prefixes. The `ai` preset now expands to package IDs (`Microsoft.WindowsAppSDK.AI`) rather than namespace prefixes. A `.dynwinrt-managed` marker file inside the output dir gates safe re-wipes. | -| **v2.1** | Per-package winmd categorization (emit / ref-only / skip) added to `JsBindingsPresets`. The `Microsoft.WindowsAppSDK.WinUI` package is now dropped entirely from JS bindings (pure XAML, unusable at dynwinrt runtime); `Microsoft.WindowsAppSDK.InteractiveExperiences` flows through `--ref` only (its primitive types stay available for type resolution but no bindings are emitted for the XAML/Composition runtime classes it ships). | -| **v2.2** | `.winapp/winmds.lock.json` audit + cache artifact. `winapp restore` records every resolved (package, version, category, winmd paths) tuple to a versioned JSON lockfile; `winapp node jsbindings add` reads it first for a no-HTTP-no-glob fast path. Transparent fallback to live discovery when the file is missing or schema-mismatched, so older workspaces keep working unchanged. | -| **v2.3** | Lockfile gets staleness detection (SHA-256 of yaml `packages:` block + winmd path existence check) and atomic write (tmp + rename) — drift between `restore` and `node jsbindings add` no longer silently uses stale data. New yaml fields `skipPackages` / `refOnlyPackages` / `emitPackages` let users override the built-in per-package classification (`Microsoft.WindowsAppSDK.WinUI` = skip, `.InteractiveExperiences` = ref-only). `node jsbindings add --force` now patches the existing `jsBindings:` block instead of overwriting it from scratch — user-edited `extraTypes:` / `additionalWinmds:` / etc. survive. Changing the `output:` directory wipes the old managed bindings (if `.dynwinrt-managed` marker present). | +| Want bindings but already ran `init` without them | Edit `winapp.yaml`, add `jsBindings: {}` (and optionally `cppProjections: false` if you don't want C++ projections), then run `npx winapp restore`. | --- @@ -428,4 +351,4 @@ The feature has shipped in incremental waves; user-visible additions: - [`@microsoft/dynwinrt`](https://github.com/microsoft/dynwinrt) — the runtime FFI bridge - [`@microsoft/dynwinrt-codegen`](https://github.com/microsoft/dynwinrt) — the code-generation tool (lives in the same repo as `dynwinrt`) -- `winapp.yaml` schema reference (top-level): `packages:`, `jsBindings:` +- `winapp.yaml` schema reference (top-level): `packages:`, `cppProjections:`, `jsBindings:` diff --git a/docs/npm-usage.md b/docs/npm-usage.md index db8a47af..320b78d9 100644 --- a/docs/npm-usage.md +++ b/docs/npm-usage.md @@ -1,4 +1,6 @@ - +--- +ms.custom: mslearn +--- @@ -187,7 +189,7 @@ function getWinappPath(options?: GetWinappPathOptions): Promise ### `init()` -Start here for initializing a Windows app with required setup. Sets up everything needed for Windows app development: creates Package.appxmanifest with default assets, downloads Windows SDK and Windows App SDK packages, and generates projections. When SDK packages are managed (--setup-sdks stable/preview/experimental), also creates winapp.yaml to pin versions for 'restore'/'update'; with --setup-sdks none (e.g., for Rust/Tauri projects that bring their own SDK bindings), no winapp.yaml is created. Interactive by default (use --use-defaults to skip prompts). Use 'restore' instead if you cloned a repo that already has winapp.yaml. Use 'manifest generate' if you only need a manifest, or 'cert generate' if you need a development certificate for code signing. +Start here for initializing a Windows app with required setup. Sets up everything needed for Windows app development: creates Package.appxmanifest with default assets, downloads Windows SDK and Windows App SDK packages, and generates projections. When SDK packages are managed (--setup-sdks stable/preview/experimental), also creates winapp.yaml to pin versions for 'restore'/'update'; with --setup-sdks none (e.g., for Rust/Tauri projects that bring their own SDK bindings), no winapp.yaml is created. When invoked via the \@microsoft/winappcli npm package (npx winapp init), additionally asks whether to generate C++ projections, JS/TS bindings, or both. Interactive by default (use --use-defaults to skip prompts). Use 'restore' instead if you cloned a repo that already has winapp.yaml. Use 'manifest generate' if you only need a manifest, or 'cert generate' if you need a development certificate for code signing. ```typescript function init(options?: InitOptions): Promise @@ -201,10 +203,6 @@ function init(options?: InitOptions): Promise | `configDir` | `string \| undefined` | No | Directory to read/store configuration (default: current directory) | | `configOnly` | `boolean \| undefined` | No | Only handle configuration file operations (create if missing, validate if exists). Skip package installation and other workspace setup steps. | | `ignoreConfig` | `boolean \| undefined` | No | Don't use configuration file for version management | -| `jsBindings` | `boolean \| undefined` | No | Generate JS/TS bindings via dynwinrt-codegen on top of the standard init flow. Adds a 'jsBindings:' block to winapp.yaml so the binding generator runs as part of init/restore. Only available when invoked via the \@microsoft/winappcli npm package (npx winapp init --js-bindings). | -| `jsBindingsAi` | `boolean \| undefined` | No | Generate bindings for the 'ai' slice of the SDK. Implies --js-bindings (no need to pass it separately). For a custom slice that no preset covers, edit winapp.yaml and write your own packages: list under jsBindings. Known presets: ai. | -| `jsBindingsLang` | `string \| undefined` | No | Override the JS bindings language. Currently only 'js' is supported (which emits both .js and .d.ts). Reserved for forward-compat; see --js-bindings-output for activation rules. | -| `jsBindingsOutput` | `string \| undefined` | No | Override the output directory for generated JS/TS bindings (relative to workspace, default 'bindings/winrt'). Only takes effect together with --js-bindings on a fresh init; ignored on re-init when winapp.yaml already declares jsBindings:. | | `noGitignore` | `boolean \| undefined` | No | Don't update .gitignore file | | `setupSdks` | `SdkInstallMode \| undefined` | No | SDK installation mode: 'stable' (default), 'preview', 'experimental', or 'none' (skip SDK installation) | | `useDefaults` | `boolean \| undefined` | No | Do not prompt, and use default of all prompts | @@ -279,48 +277,6 @@ function manifestUpdateAssets(options: ManifestUpdateAssetsOptions): Promise -``` - -**Options:** - -| Property | Type | Required | Description | -|----------|------|----------|-------------| -| `baseDirectory` | `string \| undefined` | No | Base/root directory for the winapp workspace (default: current directory) | -| `ai` | `boolean \| undefined` | No | Generate bindings for the 'ai' slice of the SDK only. For a custom slice that no preset covers, edit winapp.yaml's jsBindings.packages after adding. Known presets: ai. | -| `configDir` | `string \| undefined` | No | Directory containing winapp.yaml (default: base-directory) | -| `force` | `boolean \| undefined` | No | Patch an existing jsBindings: block without prompting. Overwrites only output and (when a preset like --ai is supplied) the packages list; all other fields are preserved. Without --force the command refuses to clobber a pre-existing block (interactive: prompts; non-interactive: errors). | -| `output` | `string \| undefined` | No | Output directory for generated JS/TS bindings (relative to workspace, default 'bindings/winrt'). Persisted to winapp.yaml's jsBindings.output field. | -| `useDefaults` | `boolean \| undefined` | No | Do not prompt. When jsBindings: already exists in winapp.yaml, preserve it and exit 0 (idempotent). Use --force instead if you want the existing block patched non-interactively. | - -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* - ---- - -### `nodeJsbindingsGenerate()` - -Re-run dynwinrt-codegen against the existing jsBindings: block in winapp.yaml. Does NOT modify the yaml — for that, use 'node jsbindings add'. Errors if no jsBindings: block is declared. Only available when invoked via the \@microsoft/winappcli npm package (npx winapp node jsbindings generate). - -```typescript -function nodeJsbindingsGenerate(options?: NodeJsbindingsGenerateOptions): Promise -``` - -**Options:** - -| Property | Type | Required | Description | -|----------|------|----------|-------------| -| `baseDirectory` | `string \| undefined` | No | Base/root directory for the winapp workspace (default: current directory) | -| `configDir` | `string \| undefined` | No | Directory containing winapp.yaml (default: base-directory) | - -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* - ---- - ### `packageApp()` Create MSIX installer from your built app. Run after building your app. A manifest (Package.appxmanifest or appxmanifest.xml) is required for packaging - it must be in current working directory, passed as --manifest or be in the input folder. Use --cert devcert.pfx to sign for testing. Example: winapp package ./dist --manifest Package.appxmanifest --cert ./devcert.pfx @@ -1212,10 +1168,6 @@ type ManifestTemplates = "packaged" | "sparse" | `configDir` | `string \| undefined` | No | Directory to read/store configuration (default: current directory) | | `configOnly` | `boolean \| undefined` | No | Only handle configuration file operations (create if missing, validate if exists). Skip package installation and other workspace setup steps. | | `ignoreConfig` | `boolean \| undefined` | No | Don't use configuration file for version management | -| `jsBindings` | `boolean \| undefined` | No | Generate JS/TS bindings via dynwinrt-codegen on top of the standard init flow. Adds a 'jsBindings:' block to winapp.yaml so the binding generator runs as part of init/restore. Only available when invoked via the \@microsoft/winappcli npm package (npx winapp init --js-bindings). | -| `jsBindingsAi` | `boolean \| undefined` | No | Generate bindings for the 'ai' slice of the SDK. Implies --js-bindings (no need to pass it separately). For a custom slice that no preset covers, edit winapp.yaml and write your own packages: list under jsBindings. Known presets: ai. | -| `jsBindingsLang` | `string \| undefined` | No | Override the JS bindings language. Currently only 'js' is supported (which emits both .js and .d.ts). Reserved for forward-compat; see --js-bindings-output for activation rules. | -| `jsBindingsOutput` | `string \| undefined` | No | Override the output directory for generated JS/TS bindings (relative to workspace, default 'bindings/winrt'). Only takes effect together with --js-bindings on a fresh init; ignored on re-init when winapp.yaml already declares jsBindings:. | | `noGitignore` | `boolean \| undefined` | No | Don't update .gitignore file | | `setupSdks` | `SdkInstallMode \| undefined` | No | SDK installation mode: 'stable' (default), 'preview', 'experimental', or 'none' (skip SDK installation) | | `useDefaults` | `boolean \| undefined` | No | Do not prompt, and use default of all prompts | @@ -1262,30 +1214,6 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -### `NodeJsbindingsAddOptions` - -| Property | Type | Required | Description | -|----------|------|----------|-------------| -| `baseDirectory` | `string \| undefined` | No | Base/root directory for the winapp workspace (default: current directory) | -| `ai` | `boolean \| undefined` | No | Generate bindings for the 'ai' slice of the SDK only. For a custom slice that no preset covers, edit winapp.yaml's jsBindings.packages after adding. Known presets: ai. | -| `configDir` | `string \| undefined` | No | Directory containing winapp.yaml (default: base-directory) | -| `force` | `boolean \| undefined` | No | Patch an existing jsBindings: block without prompting. Overwrites only output and (when a preset like --ai is supplied) the packages list; all other fields are preserved. Without --force the command refuses to clobber a pre-existing block (interactive: prompts; non-interactive: errors). | -| `output` | `string \| undefined` | No | Output directory for generated JS/TS bindings (relative to workspace, default 'bindings/winrt'). Persisted to winapp.yaml's jsBindings.output field. | -| `useDefaults` | `boolean \| undefined` | No | Do not prompt. When jsBindings: already exists in winapp.yaml, preserve it and exit 0 (idempotent). Use --force instead if you want the existing block patched non-interactively. | -| `quiet` | `boolean \| undefined` | No | Suppress progress messages. | -| `verbose` | `boolean \| undefined` | No | Enable verbose output. | -| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | - -### `NodeJsbindingsGenerateOptions` - -| Property | Type | Required | Description | -|----------|------|----------|-------------| -| `baseDirectory` | `string \| undefined` | No | Base/root directory for the winapp workspace (default: current directory) | -| `configDir` | `string \| undefined` | No | Directory containing winapp.yaml (default: base-directory) | -| `quiet` | `boolean \| undefined` | No | Suppress progress messages. | -| `verbose` | `boolean \| undefined` | No | Enable verbose output. | -| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | - ### `PackageOptions` | Property | Type | Required | Description | diff --git a/docs/usage.md b/docs/usage.md index 826b412f..72da7735 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -36,12 +36,15 @@ winapp init [base-directory] [options] - `--use-defaults`, `--no-prompt` - Do not prompt, and use default of all prompts - `--config-only` - Only handle configuration file operations, skip package installation -**JS/TypeScript bindings flags** (npm-only — require invocation via `npx winapp …`): +**JS/TypeScript bindings (npm only):** -- `--js-bindings` - Enable jsBindings codegen as part of init/restore. Adds a `jsBindings:` block to `winapp.yaml`. See [JS bindings docs](js-bindings.md) for the full feature. -- `--js-bindings-output ` - Override the bindings output dir (default `bindings/winrt`). Only effective with `--js-bindings`. -- `--js-bindings-lang ` - Target language. Currently only `js` is supported (emits both `.js` and `.d.ts`). `py` exists in the underlying codegen but is not yet wired through here — reserved for forward-compat. -- `--js-bindings-ai` - Shorthand for `--js-bindings` + AI preset (writes `Microsoft.WindowsAppSDK.AI` to `jsBindings.packages`). Auto-registered from `JsBindingsPresets.KnownPresets`. +When invoked via the `@microsoft/winappcli` npm package (i.e. `npx winapp init` inside a Node / Electron project), `init` adds an interactive **bindings prompt** that offers: + +- **C++ projections** — generate cppwinrt headers/libs/runtimes (the standalone default) +- **JS/TS bindings** — generate typed JS/TypeScript wrappers via `dynwinrt-codegen`, skip C++ projections +- **Both** — generate both (default with `--use-defaults`) + +Picking JS or Both writes a default `jsBindings:` block to `winapp.yaml` covering the full Windows App SDK; subsequent `winapp restore` calls re-run codegen against the pinned packages. See the [JS bindings reference](js-bindings.md) for the full schema (`packages:`, `skip:`, `refOnly:`, `extraTypes:`, etc.) and the [Electron JS bindings guide](guides/electron/jsbindings.md) for the end-to-end workflow. **What it does:** @@ -154,93 +157,28 @@ winapp update --setup-sdks experimental --- -### node jsbindings add - -Layer JS/TypeScript bindings (via `dynwinrt-codegen`) onto an already-initialized workspace, **without** rerunning the SDK install pipeline. Requires an existing `winapp.yaml` and previously-restored packages. npm-only — invoke as `npx winapp node jsbindings add`. - -```bash -npx winapp node jsbindings add [base-directory] [options] -``` - -**Arguments:** - -- `base-directory` - Workspace root containing `winapp.yaml` (default: current directory) - -**Options:** - -- `--config-dir ` - Directory containing `winapp.yaml` (default: current directory) -- `--output ` - Output dir for generated `.js` + `.d.ts`, persisted to `jsBindings.output` (default `bindings/winrt`) -- `--force` - Replace an existing `jsBindings:` block without prompting (refuses by default to avoid clobbering hand edits) -- `--ai` - Use the AI preset (writes `Microsoft.WindowsAppSDK.AI` to `jsBindings.packages`). Auto-registered from `JsBindingsPresets.KnownPresets` — one flag per preset. - -**What it does:** - -- Reads `winapp.yaml`, adds (or replaces with `--force`) a `jsBindings:` block from the CLI options -- Discovers winmds via `.winapp/winmds.lock.json` (fast path, written by `restore`) or via NuGet cache walk + transitive-deps expansion (fallback when the lockfile is missing) -- Spawns `@microsoft/dynwinrt-codegen` to emit `.js` + `.d.ts` into the output dir -- Auto-injects `@microsoft/dynwinrt` as a production dep in your `package.json` so generated bindings can `import` it at runtime - -**Examples:** - -```bash -# Add the AI preset to an existing workspace -npx winapp node jsbindings add --ai - -# Add the full surface (no preset) -npx winapp node jsbindings add - -# Replace an existing jsBindings: block -npx winapp node jsbindings add --ai --force - -# Custom output directory -npx winapp node jsbindings add --ai --output src/generated/winrt -``` - -> See [JS bindings docs](js-bindings.md) for the full feature reference, including `winapp.yaml` schema, presets, per-package winmd categorization, and the `winmds.lock.json` audit/cache artifact. - ---- - -### node jsbindings generate - -Re-run `dynwinrt-codegen` against the **existing** `jsBindings:` block in `winapp.yaml`. Does **not** mutate the yaml — for that, use `node jsbindings add`. Errors if no `jsBindings:` block is declared. npm-only — invoke as `npx winapp node jsbindings generate`. - -```bash -npx winapp node jsbindings generate [base-directory] [options] -``` - -**Arguments:** - -- `base-directory` - Workspace root containing `winapp.yaml` (default: current directory) +### JS/TypeScript bindings (via `init` / `restore`) -**Options:** +JS/TS bindings are configured by declaring a `jsBindings:` block in `winapp.yaml` and generated by `winapp init` (first run) or `winapp restore` (subsequent runs). There is no separate `node jsbindings` sub-command — the flow is unified with the rest of the workspace lifecycle: -- `--config-dir ` - Directory containing `winapp.yaml` (default: current directory) +| Want to … | Command | +|---|---| +| Bootstrap a fresh workspace with bindings | `npx winapp init` (pick **JS** or **Both** at the prompt; default with `--use-defaults` is Both) | +| Add `jsBindings:` to an existing workspace | Edit `winapp.yaml` to add a `jsBindings:` block (e.g. `jsBindings: {}` for the full SDK), then run `npx winapp restore` | +| Re-run codegen after editing `jsBindings:` | `npx winapp restore` | +| Re-run codegen as part of full restore (also handles NuGet + cppwinrt) | `npx winapp restore` | -**What it does:** +**What runs during `restore` when `jsBindings:` is declared:** - Reads the existing `jsBindings:` block from `winapp.yaml` (no mutation) -- Resolves winmds via `.winapp/winmds.lock.json` (fast path) or NuGet cache walk (fallback) -- Spawns `@microsoft/dynwinrt-codegen` to emit `.js` + `.d.ts` into the configured `jsBindings.output` directory +- Resolves winmds via NuGet cache walk + transitive-deps expansion +- Spawns `@microsoft/dynwinrt-codegen` to emit `.js` + `.d.ts` into the configured `jsBindings.output` directory (default `bindings/winrt/`) - Replaces the previous output dir atomically (stage-then-swap); previous bindings are preserved on codegen failure +- Auto-injects `@microsoft/dynwinrt` as a production dep in your `package.json` so generated bindings can `import` it at runtime -**When to use which command:** - -| Want to … | Command | -|---|---| -| Bootstrap a fresh workspace with bindings | `winapp init --js-bindings` (or `--js-bindings-ai`) | -| Declare `jsBindings:` on an existing workspace | `node jsbindings add` | -| Re-run codegen after editing `jsBindings:` by hand | `node jsbindings generate` | -| Re-run codegen as part of full restore (also handles NuGet + cppwinrt) | `winapp restore` | - -**Examples:** - -```bash -# Regenerate bindings against the existing winapp.yaml jsBindings: block -npx winapp node jsbindings generate +JS/TS bindings are **npm-only** — they require invocation via the `@microsoft/winappcli` npm package because they pull in `@microsoft/dynwinrt-codegen` as a transitive dep. The interactive bindings prompt during `init` only fires when invoked via the npm shim (`npx winapp …`); the standalone winget CLI does not surface it. -# Regenerate from a specific workspace -npx winapp node jsbindings generate ./packages/desktop -``` +> See [JS bindings docs](js-bindings.md) for the full `jsBindings:` yaml schema, per-package winmd categorization (skip / refOnly / emit overrides), and the `winmds.lock.json` audit artifact. --- diff --git a/samples/electron/test.Tests.ps1 b/samples/electron/test.Tests.ps1 index ededa721..8d57e402 100644 --- a/samples/electron/test.Tests.ps1 +++ b/samples/electron/test.Tests.ps1 @@ -110,15 +110,16 @@ Describe "Electron Sample" { } finally { Pop-Location } } - It "Should initialize winapp workspace with JS bindings (AI preset)" -Skip:$script:skip { - # `init --js-bindings-ai` is the fresh-project shortcut: it - # bootstraps the workspace AND runs codegen in one step, so we - # use it here in place of two separate calls (init + add). The - # AI preset is the narrowest (~7 winmds → ~65 .js, <5s on hot - # cache) and is the only ships-today preset. + It "Should initialize winapp workspace with JS bindings and C++ projections" -Skip:$script:skip { + # `init --use-defaults` invoked via the npm shim auto-picks "Both" + # at the bindings prompt (C++ projections + JS/TS bindings) and + # runs codegen in one step. No yaml flag is needed; the prompt + # only fires when WINAPP_CLI_CALLER=nodejs-package (set by the + # `npx winapp` shim, which Invoke-WinappCommand resolves to here + # after Install-WinappNpmPackage). Push-Location $script:appDir try { - Invoke-WinappCommand -Arguments "init . --use-defaults --setup-sdks=stable --js-bindings-ai" + Invoke-WinappCommand -Arguments "init . --use-defaults --setup-sdks=stable" } finally { Pop-Location } } @@ -129,20 +130,20 @@ Describe "Electron Sample" { } # ── JS bindings smoke (v2.x) ───────────────────────────────────── - # Verify the init --js-bindings-ai path produced the expected - # bindings output, lockfile, and runtime dep — and that the - # read-only `generate` re-run path still works against the - # already-mutated workspace. + # Verify the npm-caller init path produced the expected bindings + # output, lockfile, and runtime dep — and that re-running `restore` + # is idempotent (no winapp.yaml mutation). It "Should have generated bindings/winrt/ with the managed marker" -Skip:$script:skip { $bindingsDir = Join-Path $script:appDir "bindings\winrt" $bindingsDir | Should -Exist # Marker proves the staging-then-swap completed. (Join-Path $bindingsDir ".dynwinrt-managed") | Should -Exist - # AI preset generates around 65 .js files; assert at least a - # handful to catch the "0 files generated" regression. + # Full WinAppSDK generates hundreds of .js files; assert a + # generous lower bound to catch the "0 files generated" regression + # without being brittle to upstream SDK changes. $jsCount = (Get-ChildItem -Path $bindingsDir -Filter '*.js' -ErrorAction SilentlyContinue).Count - $jsCount | Should -BeGreaterThan 10 -Because "AI preset should generate 60+ JS files" + $jsCount | Should -BeGreaterThan 50 -Because "Default jsBindings (full WinAppSDK) should generate many JS files" } It "Should inject @microsoft/dynwinrt as a runtime dep in package.json" -Skip:$script:skip { @@ -151,11 +152,12 @@ Describe "Electron Sample" { $pkgPath = Join-Path $script:appDir "package.json" $pkg = Get-Content $pkgPath -Raw | ConvertFrom-Json $pkg.dependencies.'@microsoft/dynwinrt' | Should -Not -BeNullOrEmpty ` - -Because "init --js-bindings-ai must auto-inject the runtime dep" + -Because "init via npm shim with JS bindings must auto-inject the runtime dep" } It "Should write a winmds.lock.json under .winapp/" -Skip:$script:skip { - # Seeded by restore (during init); enables the add fast path. + # Seeded by restore (during init); diagnostic record of the + # winmd → package mapping at codegen time. $lockfilePath = Join-Path $script:appDir ".winapp\winmds.lock.json" $lockfilePath | Should -Exist $lockfile = Get-Content $lockfilePath -Raw | ConvertFrom-Json @@ -163,22 +165,23 @@ Describe "Electron Sample" { $lockfile.packages | Should -Not -BeNullOrEmpty -Because "Lockfile should record discovered packages" } - It "Should re-run codegen via 'node jsbindings generate' without mutating winapp.yaml" -Skip:$script:skip { - # `generate` is the read-only regen path — it must not modify - # winapp.yaml. Capture the yaml hash before/after to prove it. + It "Should re-run codegen via 'winapp restore' without mutating winapp.yaml" -Skip:$script:skip { + # `restore` is the read-only re-run path against pinned yaml — + # it must not modify winapp.yaml. Capture the yaml hash + # before/after to prove it. $yamlPath = Join-Path $script:appDir "winapp.yaml" $bindingsDir = Join-Path $script:appDir "bindings\winrt" $hashBefore = (Get-FileHash -Path $yamlPath -Algorithm SHA256).Hash Push-Location $script:appDir try { - Invoke-WinappCommand -Arguments "node jsbindings generate" + Invoke-WinappCommand -Arguments "restore" } finally { Pop-Location } $hashAfter = (Get-FileHash -Path $yamlPath -Algorithm SHA256).Hash - $hashAfter | Should -Be $hashBefore -Because "generate must not mutate winapp.yaml" + $hashAfter | Should -Be $hashBefore -Because "restore must not mutate winapp.yaml" (Join-Path $bindingsDir ".dynwinrt-managed") | Should -Exist ` - -Because "generate should leave the managed marker in place after regen" + -Because "restore should leave the managed marker in place after regen" } It "Should create a C++ native addon" -Skip:$script:skip { diff --git a/scripts/generate-llm-docs.ps1 b/scripts/generate-llm-docs.ps1 index df033bb8..48cd069d 100644 --- a/scripts/generate-llm-docs.ps1 +++ b/scripts/generate-llm-docs.ps1 @@ -109,7 +109,7 @@ $SkillsDir = $SkillsPath # Skill → CLI command mapping for auto-generated options/arguments tables # Each skill maps to one or more CLI commands whose options/arguments should be included $SkillCommandMap = @{ - "setup" = @("init", "restore", "update", "run", "node jsbindings add", "node jsbindings generate", "unregister") + "setup" = @("init", "restore", "update", "run", "unregister") "package" = @("package", "create-external-catalog") "identity" = @("create-debug-identity") "signing" = @("cert generate", "cert install", "cert info", "sign") diff --git a/src/winapp-CLI/WinApp.Cli.Tests/AddJsBindingsCommandTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/AddJsBindingsCommandTests.cs deleted file mode 100644 index 983a1672..00000000 --- a/src/winapp-CLI/WinApp.Cli.Tests/AddJsBindingsCommandTests.cs +++ /dev/null @@ -1,452 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using Microsoft.Extensions.DependencyInjection; -using WinApp.Cli.Commands; -using WinApp.Cli.Models; -using WinApp.Cli.Services; - -namespace WinApp.Cli.Tests; - -// Tests for AddJsBindingsCommand — focus on yaml mutations + npm-shim gate. -// Codegen result is not asserted (no real NuGet cache → exit 1 from "no -// winmds"); yaml is mutated before codegen so state assertions still hold. -// [DoNotParallelize] because tests mutate WINAPP_CLI_CALLER process-wide. -[TestClass] -[DoNotParallelize] -public class AddJsBindingsCommandTests : BaseCommandTests -{ - private string? _savedCaller; - - [TestInitialize] - public void TestSetup() - { - _savedCaller = Environment.GetEnvironmentVariable("WINAPP_CLI_CALLER"); - // Default to the npm-shim caller; tests that need to assert the gate - // override this explicitly. - Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); - } - - [TestCleanup] - public void TestTeardown() - { - Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", _savedCaller); - } - - // Helper: write a minimal valid winapp.yaml (packages: only) so the - // add command sees a "post-init" workspace. Returns the absolute path. - private async Task WriteMinimalYamlAsync() - { - var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); - await File.WriteAllTextAsync(configPath, - "packages:\n - name: Microsoft.WindowsAppSDK\n version: 1.8.39\n"); - return configPath; - } - - // Helper: write a winapp.yaml that already has a jsBindings: block. - // Used to exercise the "existing block" branches (force / non-force / - // non-interactive). - private async Task WriteYamlWithJsBindingsAsync(string output = "old/output") - { - var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); - await File.WriteAllTextAsync(configPath, - "packages:\n" - + " - name: Microsoft.WindowsAppSDK\n" - + " version: 1.8.39\n" - + "jsBindings:\n" - + $" output: {output}\n" - + " lang: js\n"); - return configPath; - } - - [TestMethod] - public async Task AddJsBindings_WithoutNpmCaller_ExitsWithActionableError() - { - // Same npm-shim gating as InitCommand --js-bindings. - Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", null); - - var addCmd = GetRequiredService(); - var args = new[] { _tempDirectory.FullName }; - - var exitCode = await ParseAndInvokeWithCaptureAsync(addCmd, args); - - Assert.AreEqual(1, exitCode, "Non-npm caller must exit 1"); - var stderr = ConsoleStdErr.ToString(); - StringAssert.Contains(stderr, "'node jsbindings add' requires the @microsoft/winappcli npm package", - "Error must name the command and the required package"); - StringAssert.Contains(stderr, "npx winapp node jsbindings add", - "Error must include the recovery invocation"); - - // No yaml mutation should occur — bailed before service ran. - var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); - Assert.IsFalse(File.Exists(configPath), - "winapp.yaml must not be touched when the npm-shim gate fails"); - } - - [TestMethod] - public async Task AddJsBindings_NoYaml_ReturnsErrorWithInitHint() - { - // No init was ever run → there's no winapp.yaml. add jsbindings is a - // layered command and must refuse instead of silently bootstrapping. - var addCmd = GetRequiredService(); - var args = new[] { _tempDirectory.FullName }; - - var exitCode = await ParseAndInvokeWithCaptureAsync(addCmd, args); - - Assert.AreEqual(1, exitCode, "Missing winapp.yaml must surface an error"); - // Failure goes to stderr (via ILogger.LogError); stdout stays clean - // so non-interactive consumers can rely on it for success payloads. - var stderr = ConsoleStdErr.ToString(); - StringAssert.Contains(stderr, "winapp.yaml not found", - "Error must explain the missing yaml precondition (routed to stderr)"); - StringAssert.Contains(stderr, "winapp init", - "Error must point users at the bootstrap command"); - } - - [TestMethod] - public async Task AddJsBindings_FreshWorkspace_AddsJsBindingsBlock() - { - // Yaml exists, no jsBindings block → inject defaults + persist. - var configPath = await WriteMinimalYamlAsync(); - - var addCmd = GetRequiredService(); - var args = new[] { _tempDirectory.FullName }; - - await ParseAndInvokeWithCaptureAsync(addCmd, args); - - var content = await File.ReadAllTextAsync(configPath); - StringAssert.Contains(content, "jsBindings:", - "jsBindings: block must be injected by add jsbindings"); - StringAssert.Contains(content, "lang: js", - "Default lang=js must be persisted"); - StringAssert.Contains(content, "bindings/winrt", - "Default output dir must be persisted"); - StringAssert.Contains(content, "packages:", - "Existing packages section must be preserved (non-destructive)"); - StringAssert.Contains(content, "Microsoft.WindowsAppSDK", - "Pre-existing package pin must survive the add"); - } - - [TestMethod] - public async Task AddJsBindings_WithOutput_PersistsCustomOutputDir() - { - // --output should override the default 'bindings/winrt' and land - // verbatim in the yaml's jsBindings.output field. - var configPath = await WriteMinimalYamlAsync(); - - var addCmd = GetRequiredService(); - var args = new[] { _tempDirectory.FullName, "--output", "src/generated/winrt" }; - - await ParseAndInvokeWithCaptureAsync(addCmd, args); - - var content = await File.ReadAllTextAsync(configPath); - StringAssert.Contains(content, "output: src/generated/winrt", - "--output must override the default and persist to yaml"); - } - - [TestMethod] - public async Task AddJsBindings_AiAlias_PopulatesPackages() - { - // --ai populates jsBindings.packages with the preset's NuGet IDs. - var configPath = await WriteMinimalYamlAsync(); - var aiPackages = JsBindingsPresets.KnownPresets["ai"]; - Assert.IsTrue(aiPackages.Count > 0, "Test precondition: ai preset must declare package IDs"); - - var addCmd = GetRequiredService(); - var args = new[] { _tempDirectory.FullName, "--ai" }; - - await ParseAndInvokeWithCaptureAsync(addCmd, args); - - var content = await File.ReadAllTextAsync(configPath); - StringAssert.Contains(content, "packages:", - "--ai must produce a packages: yaml field under jsBindings"); - foreach (var pkg in aiPackages) - { - StringAssert.Contains(content, pkg, - $"AI preset package id {pkg} must appear in the persisted yaml"); - } - } - - [TestMethod] - public async Task AddJsBindings_ExistingBlockNoForce_NonInteractive_ReturnsError() - { - // Non-interactive runtime → prompt throws → we surface the --force - // hint instead of clobbering. - var configPath = await WriteYamlWithJsBindingsAsync("custom/old"); - - var addCmd = GetRequiredService(); - var args = new[] { _tempDirectory.FullName }; - - var exitCode = await ParseAndInvokeWithCaptureAsync(addCmd, args); - - Assert.AreEqual(1, exitCode, - "Existing block + no --force + non-interactive must exit 1"); - // --force hint goes to stderr via ILogger.LogError (M11 fix). - var stderr = ConsoleStdErr.ToString(); - StringAssert.Contains(stderr, "--force", - "Error must point users at --force to bypass the prompt in CI (routed to stderr)"); - - // Yaml must NOT be mutated — the original output should still be there. - var content = await File.ReadAllTextAsync(configPath); - StringAssert.Contains(content, "custom/old", - "Original jsBindings block must be preserved when the prompt rejects"); - } - - [TestMethod] - public async Task AddJsBindings_ExistingBlockWithForce_ReplacesBlock() - { - // --force bypasses the prompt entirely (silent replace). The new - // block should overwrite the old one with the CLI-supplied output. - var configPath = await WriteYamlWithJsBindingsAsync("custom/old"); - - var addCmd = GetRequiredService(); - var args = new[] { _tempDirectory.FullName, "--force", "--output", "fresh/output" }; - - await ParseAndInvokeWithCaptureAsync(addCmd, args); - - var content = await File.ReadAllTextAsync(configPath); - StringAssert.Contains(content, "fresh/output", - "--force must replace the existing jsBindings block with the new one"); - Assert.IsFalse(content.Contains("custom/old"), - "Old jsBindings block must be gone after --force replace"); - } - - // scripted callers (CI / build steps) need a safe - // non-interactive no-op that preserves an existing jsBindings: block - // without prompting and without overwriting. - [TestMethod] - public async Task AddJsBindings_ExistingBlockWithUseDefaults_PreservesAndExitsZero() - { - var configPath = await WriteYamlWithJsBindingsAsync("custom/old"); - var originalContent = await File.ReadAllTextAsync(configPath); - - var addCmd = GetRequiredService(); - var args = new[] { _tempDirectory.FullName, "--use-defaults", "--output", "should-be-ignored" }; - - var exitCode = await ParseAndInvokeWithCaptureAsync(addCmd, args); - - Assert.AreEqual(0, exitCode, - "--use-defaults must exit 0 (idempotent no-op) when jsBindings already exists."); - - var content = await File.ReadAllTextAsync(configPath); - Assert.AreEqual(originalContent, content, - "File on disk must be byte-identical — --use-defaults preserves, does NOT mutate."); - Assert.IsFalse(content.Contains("should-be-ignored"), - "--output override must be ignored when --use-defaults preserves the existing block."); - } - - [TestMethod] - public async Task AddJsBindings_NoExistingBlockWithUseDefaults_NormalAddFlow() - { - // --use-defaults is a no-op marker for the existing-block case only. - // When there's NO block yet, the command proceeds normally (adds). - await WriteMinimalYamlAsync(); - - var addCmd = GetRequiredService(); - var args = new[] { _tempDirectory.FullName, "--use-defaults", "--output", "bindings/winrt" }; - - // Exit may be 1 (no NuGet cache → "No .winmd files found") OR 0 - // (somehow finds metadata); we assert the yaml mutation, not the - // codegen result. Either way, the yaml MUST have been patched - // because --use-defaults shouldn't short-circuit on a fresh add. - await ParseAndInvokeWithCaptureAsync(addCmd, args); - - var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); - var content = await File.ReadAllTextAsync(configPath); - StringAssert.Contains(content, "jsBindings:", - "Fresh add (no existing block) with --use-defaults must still write the block."); - } - - [TestMethod] - public async Task AddJsBindings_ForceAndUseDefaultsTogether_RejectedAsMutuallyExclusive() - { - await WriteYamlWithJsBindingsAsync("custom/old"); - - var addCmd = GetRequiredService(); - var args = new[] { _tempDirectory.FullName, "--force", "--use-defaults" }; - - var exitCode = await ParseAndInvokeWithCaptureAsync(addCmd, args); - - Assert.AreEqual(1, exitCode); - var stderr = ConsoleStdErr.ToString(); - StringAssert.Contains(stderr, "mutually exclusive", - "Error must call out the conflict so users pick one."); - } - - // interactive overwrite prompt — "No" answer. - [TestMethod] - public async Task AddJsBindings_ExistingBlockNoForce_PromptNo_PreservesYamlAndSkipsCodegen() - { - var configPath = await WriteYamlWithJsBindingsAsync("custom/old"); - var originalContent = await File.ReadAllTextAsync(configPath); - - // Drive the ConfirmationPrompt with "n" + Enter. The default for - // Spectre's ConfirmationPrompt is "Yes", so we have to explicitly - // type N to override. - TestAnsiConsole.Input.PushTextWithEnter("n"); - - var addCmd = GetRequiredService(); - var args = new[] { _tempDirectory.FullName }; - - var exitCode = await ParseAndInvokeWithCaptureAsync(addCmd, args); - - Assert.AreEqual(0, exitCode, - "Prompt 'No' must exit 0 (user chose to preserve)."); - - var content = await File.ReadAllTextAsync(configPath); - Assert.AreEqual(originalContent, content, - "Yaml on disk must be unchanged after prompt 'No'."); - } - - // interactive overwrite prompt — "Yes" answer patches. - [TestMethod] - public async Task AddJsBindings_ExistingBlockNoForce_PromptYes_PatchesYamlAndProceedsToCodegen() - { - var configPath = await WriteYamlWithJsBindingsAsync("custom/old"); - TestAnsiConsole.Input.PushTextWithEnter("y"); - - var addCmd = GetRequiredService(); - var args = new[] { _tempDirectory.FullName, "--output", "fresh/output" }; - - // Exit may be 1 because the test env has no NuGet cache → codegen - // discovery returns 0 winmds → "No .winmd files found" → 1. - // We assert YAML mutation, not codegen result; the YAML patch - // happens BEFORE codegen runs so it's observable either way. - await ParseAndInvokeWithCaptureAsync(addCmd, args); - - var content = await File.ReadAllTextAsync(configPath); - StringAssert.Contains(content, "fresh/output", - "Prompt 'Yes' must patch the existing block with the new output."); - Assert.IsFalse(content.Contains("custom/old"), - "Old output must be gone after 'Yes' patch."); - } - - [TestMethod] - public async Task AddJsBindings_ExistingBlockWithForce_PreservesUserCustomizedFields() - { - // --force is a PATCH (not replace): CLI fields overwrite; everything - // else (extraTypes / additionalWinmds/Refs / skip+refOnly+emit - // overrides) must survive. - var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); - await File.WriteAllTextAsync(configPath, - "packages:\n" - + " - name: Microsoft.WindowsAppSDK\n" - + " version: 1.8.39\n" - + "jsBindings:\n" - + " output: custom/old\n" - + " lang: js\n" - + " packages:\n" - + " - Microsoft.WindowsAppSDK.OldPreset\n" - + " additionalWinmds:\n" - + " - vendor/MyCo.Foo.winmd\n" - + " additionalRefs:\n" - + " - vendor/BigSDK.winmd\n" - + " skipPackages:\n" - + " - Custom.SkipMe.Package\n" - + " refOnlyPackages:\n" - + " - Custom.RefOnlyMe.Package\n" - + " emitPackages:\n" - + " - Microsoft.WindowsAppSDK.WinUI\n" - + " extraTypes:\n" - + " - namespace: Windows.Foundation\n" - + " classes:\n" - + " - Uri\n" - + " - Calendar\n"); - - var addCmd = GetRequiredService(); - // --ai overrides packages: with the AI preset, --output overrides output:; - // every other field above must survive. - var args = new[] { _tempDirectory.FullName, "--force", "--ai", "--output", "fresh/output" }; - - await ParseAndInvokeWithCaptureAsync(addCmd, args); - - var content = await File.ReadAllTextAsync(configPath); - - // CLI-touched fields: replaced. - StringAssert.Contains(content, "fresh/output", - "--output must overwrite jsBindings.output"); - StringAssert.Contains(content, "Microsoft.WindowsAppSDK.AI", - "--ai must overwrite jsBindings.packages with the preset's IDs"); - Assert.IsFalse(content.Contains("custom/old"), "Old output must be gone"); - Assert.IsFalse(content.Contains("OldPreset"), "Old packages list must be gone"); - - // Untouched user fields: preserved. - StringAssert.Contains(content, "vendor/MyCo.Foo.winmd", - "additionalWinmds entries must survive --force patch"); - StringAssert.Contains(content, "vendor/BigSDK.winmd", - "additionalRefs entries must survive --force patch"); - StringAssert.Contains(content, "Custom.SkipMe.Package", - "skipPackages overrides must survive --force patch"); - StringAssert.Contains(content, "Custom.RefOnlyMe.Package", - "refOnlyPackages overrides must survive --force patch"); - StringAssert.Contains(content, "Microsoft.WindowsAppSDK.WinUI", - "emitPackages overrides must survive --force patch"); - StringAssert.Contains(content, "Windows.Foundation", - "extraTypes namespace must survive --force patch"); - StringAssert.Contains(content, "Uri", - "extraTypes classes must survive --force patch"); - StringAssert.Contains(content, "Calendar", - "extraTypes classes must survive --force patch (2nd entry)"); - } - - [TestMethod] - public async Task AddJsBindings_KebabCaseAlias_RoutesToSameHandler() - { - // `node js-bindings add` (kebab-case alias on parent) routes correctly. - var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); - await File.WriteAllTextAsync(configPath, - "packages:\n" - + " - name: Microsoft.WindowsAppSDK\n" - + " version: 1.8.39\n"); - - var rootCmd = GetRequiredService(); - var args = new[] { "node", "js-bindings", "add", _tempDirectory.FullName }; - - var parseResult = rootCmd.Parse(args); - Assert.AreEqual(0, parseResult.Errors.Count, - "Kebab-case alias must parse without errors: " - + string.Join("; ", parseResult.Errors.Select(e => e.Message))); - - Assert.IsInstanceOfType(parseResult.CommandResult.Command, - "`node js-bindings add` must route to AddJsBindingsCommand via the parent alias."); - } - - [TestMethod] - public async Task AddJsBindings_WithConfigDirSeparateFromWorkspace_PatchesIntendedYaml() - { - // --config-dir lets the user point at a different directory containing - // winapp.yaml while keeping the workspace (binding-output anchor) elsewhere. - var configDir = _tempDirectory.CreateSubdirectory("config-dir"); - var workspaceDir = _tempDirectory.CreateSubdirectory("workspace"); - - var configPath = Path.Combine(configDir.FullName, "winapp.yaml"); - await File.WriteAllTextAsync(configPath, - "packages:\n" - + " - name: Microsoft.WindowsAppSDK\n" - + " version: 1.8.39\n"); - - // A decoy yaml in the workspace should NOT be touched. - var decoyPath = Path.Combine(workspaceDir.FullName, "winapp.yaml"); - await File.WriteAllTextAsync(decoyPath, "# decoy — must not be modified\n"); - - var addCmd = GetRequiredService(); - var args = new[] - { - workspaceDir.FullName, - "--config-dir", configDir.FullName, - "--output", "bindings/winrt", - }; - - await ParseAndInvokeWithCaptureAsync(addCmd, args); - - var actualConfig = await File.ReadAllTextAsync(configPath); - StringAssert.Contains(actualConfig, "jsBindings:", - "--config-dir target yaml must be patched"); - - var decoy = await File.ReadAllTextAsync(decoyPath); - StringAssert.Contains(decoy, "# decoy", - "Workspace-directory yaml must NOT be touched when --config-dir is set"); - Assert.IsFalse(decoy.Contains("jsBindings:"), - "Workspace yaml must remain unpatched."); - } -} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/AddJsBindingsOrchestrationTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/AddJsBindingsOrchestrationTests.cs deleted file mode 100644 index 2c87d664..00000000 --- a/src/winapp-CLI/WinApp.Cli.Tests/AddJsBindingsOrchestrationTests.cs +++ /dev/null @@ -1,1016 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using Microsoft.Extensions.DependencyInjection; -using WinApp.Cli.Commands; -using WinApp.Cli.Helpers; -using WinApp.Cli.Models; -using WinApp.Cli.Services; -using WinApp.Cli.Tests.TestDoubles; - -namespace WinApp.Cli.Tests; - -// Hermetic orchestration tests for AddJsBindingsAsync. Injects a fake -// codegen so the executable is never spawned. Fast-path vs fallback is -// driven by writing (or omitting) .winapp/winmds.lock.json. -// [DoNotParallelize] because the tests mutate WINAPP_CLI_CALLER. -[TestClass] -[DoNotParallelize] -public class AddJsBindingsOrchestrationTests : BaseCommandTests -{ - private static readonly string[] _arr00 = ["Lens", "Sensor"]; - - private FakeDynWinrtCodegenService _fakeCodegen = null!; - - protected override IServiceCollection ConfigureServices(IServiceCollection services) - { - // Swap the real codegen for the recording fake. - _fakeCodegen = new FakeDynWinrtCodegenService(); - var existing = services.FirstOrDefault(d => d.ServiceType == typeof(IDynWinrtCodegenService)); - if (existing is not null) - { - services.Remove(existing); - } - services.AddSingleton(_fakeCodegen); - return services; - } - - [TestInitialize] - public void SetNpmCallerEnv() - { - // AddJsBindingsCommand gates on this exact env value (NpmShimCaller). - Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); - } - - [TestCleanup] - public void ClearNpmCallerEnv() - { - Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", null); - } - - private DirectoryInfo SetUpWorkspaceWithLockfile( - string yamlPackagesBlock = "packages:\n - name: Microsoft.WindowsAppSDK\n version: 1.8.39\n", - params (string name, string version, string category, string[] winmdPaths)[] lockfilePackages) - { - var ws = _tempDirectory; - File.WriteAllText(Path.Combine(ws.FullName, "winapp.yaml"), yamlPackagesBlock); - - var winappDir = ws.CreateSubdirectory(".winapp"); - - // Match the hash AddJsBindingsAsync computes; otherwise fast-path - // rejects as stale. - var loadedConfig = new ConfigService(new CurrentDirectoryProvider(ws.FullName)) - { - ConfigPath = new FileInfo(Path.Combine(ws.FullName, "winapp.yaml")), - }; - var configForHash = loadedConfig.Load(); - var hash = YamlPackagesHasher.Compute(configForHash.Packages); - - var lockfile = new WinmdsLockfile - { - Schema = WinmdsLockfile.CurrentSchema, - GeneratedAt = DateTimeOffset.UtcNow.ToString("O"), - NugetCacheDir = ws.FullName, - YamlPackagesHash = hash, - Packages = lockfilePackages.Select(p => new WinmdsLockfilePackage - { - Name = p.name, - Version = p.version, - Category = p.category, - Winmds = p.winmdPaths.ToList(), - }).ToList(), - }; - - // Ensure every winmd path exists on disk — fast-path validates this. - foreach (var pkg in lockfilePackages) - { - foreach (var path in pkg.winmdPaths) - { - Directory.CreateDirectory(Path.GetDirectoryName(path)!); - if (!File.Exists(path)) - { - File.WriteAllText(path, "stub winmd"); - } - } - } - - var json = System.Text.Json.JsonSerializer.Serialize( - lockfile, WinmdsLockfileJsonContext.Default.WinmdsLockfile); - File.WriteAllText(Path.Combine(winappDir.FullName, "winmds.lock.json"), json); - - return ws; - } - - // ------------------------------------------------------------------------- - // true success-path test - // ------------------------------------------------------------------------- - - [TestMethod] - public async Task AddJsBindings_HappyPath_ExitsZero_GeneratesBindings_InjectsRuntimeDep() - { - // Workspace has a lockfile with one AI package + its winmds; fast-path - // partitions, calls (fake) codegen, exits 0. - var aiWinmd = Path.Combine(_tempDirectory.FullName, "fake-cache", - "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); - - SetUpWorkspaceWithLockfile( - lockfilePackages: new[] - { - ("Microsoft.WindowsAppSDK", "1.8.39", "emit", Array.Empty()), - ("Microsoft.WindowsAppSDK.AI", "1.8.39", "emit", new[] { aiWinmd }), - }); - - // Seed a minimal package.json so the runtime-dep injection has a - // file to read/write. - File.WriteAllText( - Path.Combine(_tempDirectory.FullName, "package.json"), - """{"name":"hosting-app","version":"1.0.0","dependencies":{}}"""); - - var addCmd = GetRequiredService(); - var args = new[] { _tempDirectory.FullName, "--ai", "--force" }; - - var exitCode = await ParseAndInvokeWithCaptureAsync(addCmd, args); - - Assert.AreEqual(0, exitCode, - "Happy path must exit 0. Stderr: " + ConsoleStdErr.ToString()); - - // Fake codegen was invoked exactly once. - Assert.AreEqual(1, _fakeCodegen.Calls.Count, - "Codegen should be called exactly once for the bulk pass (no extraTypes)."); - - // Args sanity-check: the AI winmd is in the emit list. - var call = _fakeCodegen.Calls[0]; - Assert.IsTrue(call.EmitWinmds.Any(p => p.EndsWith("Microsoft.Windows.AI.winmd", StringComparison.OrdinalIgnoreCase)), - $"AI winmd must be in emit list. Got: {string.Join(", ", call.EmitWinmds)}"); - - // Output dir created with marker + stub file. - var output = Path.Combine(_tempDirectory.FullName, "bindings", "winrt"); - Assert.IsTrue(Directory.Exists(output), "Output dir must exist."); - Assert.IsTrue(File.Exists(Path.Combine(output, ".dynwinrt-managed")), - "Marker file must be written for next-run wipe gating."); - Assert.IsTrue(File.Exists(Path.Combine(output, "index.js")), - "Stub codegen output must be present."); - - // Yaml was patched with the AI preset. - var yaml = await File.ReadAllTextAsync(Path.Combine(_tempDirectory.FullName, "winapp.yaml")); - StringAssert.Contains(yaml, "Microsoft.WindowsAppSDK.AI", - "yaml's jsBindings.packages should now contain the AI preset."); - } - - // ------------------------------------------------------------------------- - // lockfile fast-path / stale-hash / missing-paths / fallback - // ------------------------------------------------------------------------- - - [TestMethod] - public async Task AddJsBindings_LockfileFastPath_UsedWhenHashMatches() - { - // Setup matches happy path; assert that fast-path was taken by - // verifying we never needed the NuGet cache (which is empty). - var aiWinmd = Path.Combine(_tempDirectory.FullName, "fake-cache", - "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); - SetUpWorkspaceWithLockfile( - lockfilePackages: new[] - { - ("Microsoft.WindowsAppSDK", "1.8.39", "emit", Array.Empty()), - ("Microsoft.WindowsAppSDK.AI", "1.8.39", "emit", new[] { aiWinmd }), - }); - File.WriteAllText( - Path.Combine(_tempDirectory.FullName, "package.json"), - """{"name":"app","version":"1.0.0","dependencies":{}}"""); - - var addCmd = GetRequiredService(); - var exitCode = await ParseAndInvokeWithCaptureAsync(addCmd, new[] { _tempDirectory.FullName, "--ai", "--force" }); - - Assert.AreEqual(0, exitCode); - // Lockfile path supplies the winmd directly — no NuGet cache glob. - Assert.AreEqual(1, _fakeCodegen.Calls.Count); - Assert.AreEqual(1, _fakeCodegen.Calls[0].EmitWinmds.Length); - } - - [TestMethod] - public async Task AddJsBindings_StaleYamlHash_FallsBackOrFailsCleanly() - { - // Stale-hash lockfile → fast-path rejects → fallback fails (no - // NuGet cache); never silently uses stale data. - var aiWinmd = Path.Combine(_tempDirectory.FullName, "fake-cache", - "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); - Directory.CreateDirectory(Path.GetDirectoryName(aiWinmd)!); - File.WriteAllText(aiWinmd, "stub"); - - File.WriteAllText(Path.Combine(_tempDirectory.FullName, "winapp.yaml"), - "packages:\n - name: Microsoft.WindowsAppSDK\n version: 1.8.39\n"); - - var winappDir = _tempDirectory.CreateSubdirectory(".winapp"); - - // Hash is "deadbeef" — guaranteed not to match the actual yaml. - var lockfile = new WinmdsLockfile - { - Schema = WinmdsLockfile.CurrentSchema, - GeneratedAt = DateTimeOffset.UtcNow.ToString("O"), - NugetCacheDir = _tempDirectory.FullName, - YamlPackagesHash = "deadbeef-not-a-real-hash", - Packages = new List - { - new() { Name = "Microsoft.WindowsAppSDK.AI", Version = "1.8.39", Category = "emit", - Winmds = { aiWinmd } }, - }, - }; - var json = System.Text.Json.JsonSerializer.Serialize( - lockfile, WinmdsLockfileJsonContext.Default.WinmdsLockfile); - File.WriteAllText(Path.Combine(winappDir.FullName, "winmds.lock.json"), json); - - File.WriteAllText( - Path.Combine(_tempDirectory.FullName, "package.json"), - """{"name":"app","version":"1.0.0","dependencies":{}}"""); - - var addCmd = GetRequiredService(); - var exitCode = await ParseAndInvokeWithCaptureAsync(addCmd, new[] { _tempDirectory.FullName, "--ai", "--force" }); - - // No real NuGet cache → slow-path can't resolve sub-packages. - Assert.AreNotEqual(0, exitCode, - "Stale lockfile + missing NuGet cache must fail cleanly, not silently use stale data."); - // Fake codegen must not be invoked — discovery failed first. - Assert.AreEqual(0, _fakeCodegen.Calls.Count, - "Codegen must not be called when discovery can't find any winmds."); - } - - [TestMethod] - public async Task AddJsBindings_LockfileMissingWinmdPaths_FallsBackOrFailsCleanly() - { - // Lockfile references winmd paths that don't exist on disk - // (e.g. `nuget locals all -clear` between restore and add). - var bogusAiWinmd = Path.Combine(_tempDirectory.FullName, "deleted-cache", - "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); - // Intentionally do NOT create the file. - - File.WriteAllText(Path.Combine(_tempDirectory.FullName, "winapp.yaml"), - "packages:\n - name: Microsoft.WindowsAppSDK\n version: 1.8.39\n"); - - var winappDir = _tempDirectory.CreateSubdirectory(".winapp"); - - var loadedConfig = new ConfigService(new CurrentDirectoryProvider(_tempDirectory.FullName)) - { - ConfigPath = new FileInfo(Path.Combine(_tempDirectory.FullName, "winapp.yaml")), - }.Load(); - var hash = YamlPackagesHasher.Compute(loadedConfig.Packages); - - var lockfile = new WinmdsLockfile - { - Schema = WinmdsLockfile.CurrentSchema, - GeneratedAt = DateTimeOffset.UtcNow.ToString("O"), - NugetCacheDir = _tempDirectory.FullName, - YamlPackagesHash = hash, - Packages = new List - { - new() { Name = "Microsoft.WindowsAppSDK.AI", Version = "1.8.39", Category = "emit", - Winmds = { bogusAiWinmd } }, - }, - }; - var json = System.Text.Json.JsonSerializer.Serialize( - lockfile, WinmdsLockfileJsonContext.Default.WinmdsLockfile); - File.WriteAllText(Path.Combine(winappDir.FullName, "winmds.lock.json"), json); - - File.WriteAllText( - Path.Combine(_tempDirectory.FullName, "package.json"), - """{"name":"app","version":"1.0.0","dependencies":{}}"""); - - var addCmd = GetRequiredService(); - var exitCode = await ParseAndInvokeWithCaptureAsync(addCmd, new[] { _tempDirectory.FullName, "--ai", "--force" }); - - // Expect failure (no fallback can find anything either), but the - // key assertion is the codegen wasn't called with bogus paths. - Assert.AreNotEqual(0, exitCode, - "Stale paths + no fallback data must fail cleanly."); - Assert.AreEqual(0, _fakeCodegen.Calls.Count, - "Codegen must NOT be invoked with bogus winmd paths from a stale lockfile."); - } - - [TestMethod] - public async Task AddJsBindings_CodegenThrows_PropagatesAsExit1() - { - // FailWith makes fake codegen throw; caller must surface exit 1. - var aiWinmd = Path.Combine(_tempDirectory.FullName, "fake-cache", - "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); - SetUpWorkspaceWithLockfile( - lockfilePackages: new[] - { - ("Microsoft.WindowsAppSDK.AI", "1.8.39", "emit", new[] { aiWinmd }), - }); - File.WriteAllText( - Path.Combine(_tempDirectory.FullName, "package.json"), - """{"name":"app","version":"1.0.0","dependencies":{}}"""); - - _fakeCodegen.FailWith = new InvalidOperationException("simulated codegen failure"); - - var addCmd = GetRequiredService(); - var exitCode = await ParseAndInvokeWithCaptureAsync(addCmd, new[] { _tempDirectory.FullName, "--ai", "--force" }); - - Assert.AreEqual(1, exitCode, "Codegen failure must surface as non-zero exit."); - Assert.AreEqual(1, _fakeCodegen.Calls.Count, "Codegen was invoked (and threw)."); - } - - [TestMethod] - public async Task AddJsBindings_AllScopedPackagesCategorizedAsSkip_FailsBeforeCodegen() - { - // Scope narrows to a single Skip-categorized package → empty emit - // set → must fail before spawning codegen. - var winuiWinmd = Path.Combine(_tempDirectory.FullName, "fake-cache", - "microsoft.windowsappsdk.winui", "1.8.39", "metadata", "Microsoft.WindowsAppSDK.WinUI.winmd"); - Directory.CreateDirectory(Path.GetDirectoryName(winuiWinmd)!); - File.WriteAllText(winuiWinmd, "stub"); - - // WinUI is in the default Skip set. - File.WriteAllText(Path.Combine(_tempDirectory.FullName, "winapp.yaml"), - "packages:\n" - + " - name: Microsoft.WindowsAppSDK\n" - + " version: 1.8.39\n" - + "jsBindings:\n" - + " output: bindings/winrt\n" - + " lang: js\n" - + " packages:\n" - + " - Microsoft.WindowsAppSDK.WinUI\n"); - - var winappDir = _tempDirectory.CreateSubdirectory(".winapp"); - var loadedConfig = new ConfigService(new CurrentDirectoryProvider(_tempDirectory.FullName)) - { - ConfigPath = new FileInfo(Path.Combine(_tempDirectory.FullName, "winapp.yaml")), - }.Load(); - var hash = YamlPackagesHasher.Compute(loadedConfig.Packages); - - var lockfile = new WinmdsLockfile - { - Schema = WinmdsLockfile.CurrentSchema, - GeneratedAt = DateTimeOffset.UtcNow.ToString("O"), - NugetCacheDir = _tempDirectory.FullName, - YamlPackagesHash = hash, - Packages = new List - { - new() { Name = "Microsoft.WindowsAppSDK.WinUI", Version = "1.8.39", Category = "skip", - Winmds = { winuiWinmd } }, - }, - }; - var json = System.Text.Json.JsonSerializer.Serialize( - lockfile, WinmdsLockfileJsonContext.Default.WinmdsLockfile); - File.WriteAllText(Path.Combine(winappDir.FullName, "winmds.lock.json"), json); - - File.WriteAllText( - Path.Combine(_tempDirectory.FullName, "package.json"), - """{"name":"app","version":"1.0.0","dependencies":{}}"""); - - var addCmd = GetRequiredService(); - var exitCode = await ParseAndInvokeWithCaptureAsync(addCmd, new[] { _tempDirectory.FullName }); - - Assert.AreNotEqual(0, exitCode, - "All-skipped scope must fail cleanly, not invoke codegen with no emit set."); - Assert.AreEqual(0, _fakeCodegen.Calls.Count, - "Codegen MUST NOT be invoked when there's nothing to emit."); - } - - [TestMethod] - public async Task AddJsBindings_ForceChangesOutput_OldOutputCleanupOnlyAfterCodegenSuccess() - { - // M7: --force --output change wipes a managed old dir on success, - // preserves an unmanaged one (marker-gated). - var aiWinmd = Path.Combine(_tempDirectory.FullName, "fake-cache", - "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); - SetUpWorkspaceWithLockfile( - lockfilePackages: new[] - { - ("Microsoft.WindowsAppSDK.AI", "1.8.39", "emit", new[] { aiWinmd }), - }); - - // Case A: managed old dir → must be wiped after codegen succeeds. - var managedOld = Path.Combine(_tempDirectory.FullName, "managed-old"); - Directory.CreateDirectory(managedOld); - File.WriteAllText(Path.Combine(managedOld, "Uri.js"), "// generated"); - File.WriteAllText(Path.Combine(managedOld, DynWinrtCodegenService.ManagedMarkerFileName), "# managed"); - - var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); - await File.WriteAllTextAsync(configPath, - "packages:\n" - + " - name: Microsoft.WindowsAppSDK\n" - + " version: 1.8.39\n" - + "jsBindings:\n" - + " output: managed-old\n" - + " lang: js\n" - + " packages:\n" - + " - Microsoft.WindowsAppSDK.AI\n"); - - File.WriteAllText( - Path.Combine(_tempDirectory.FullName, "package.json"), - """{"name":"app","version":"1.0.0","dependencies":{}}"""); - - var addCmd = GetRequiredService(); - var exit = await ParseAndInvokeWithCaptureAsync(addCmd, - new[] { _tempDirectory.FullName, "--force", "--output", "fresh-out" }); - - Assert.AreEqual(0, exit, "Codegen should succeed via fake."); - Assert.IsFalse(File.Exists(Path.Combine(managedOld, "Uri.js")), - "Managed old dir's files must be wiped after a successful output: change."); - - // Case B: unmanaged old dir → preserved even on success. - var unmanagedOld = Path.Combine(_tempDirectory.FullName, "unmanaged-old"); - Directory.CreateDirectory(unmanagedOld); - File.WriteAllText(Path.Combine(unmanagedOld, "user-handcraft.js"), "// hand-written"); - // NO marker. - - await File.WriteAllTextAsync(configPath, - "packages:\n" - + " - name: Microsoft.WindowsAppSDK\n" - + " version: 1.8.39\n" - + "jsBindings:\n" - + " output: unmanaged-old\n" - + " lang: js\n" - + " packages:\n" - + " - Microsoft.WindowsAppSDK.AI\n"); - - var exit2 = await ParseAndInvokeWithCaptureAsync(addCmd, - new[] { _tempDirectory.FullName, "--force", "--output", "fresh-out-2" }); - - Assert.AreEqual(0, exit2); - Assert.IsTrue(File.Exists(Path.Combine(unmanagedOld, "user-handcraft.js")), - "Unmanaged old dir's user files must NOT be wiped — marker-gated safety."); - } - - [TestMethod] - public async Task AddJsBindings_CodegenFails_OldOutputIsPreserved() - { - // M7: codegen failure must leave the old bindings dir untouched. - var aiWinmd = Path.Combine(_tempDirectory.FullName, "fake-cache", - "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); - SetUpWorkspaceWithLockfile( - lockfilePackages: new[] - { - ("Microsoft.WindowsAppSDK.AI", "1.8.39", "emit", new[] { aiWinmd }), - }); - - var managedOld = Path.Combine(_tempDirectory.FullName, "managed-old"); - Directory.CreateDirectory(managedOld); - File.WriteAllText(Path.Combine(managedOld, "Uri.js"), "// generated"); - File.WriteAllText(Path.Combine(managedOld, DynWinrtCodegenService.ManagedMarkerFileName), "# managed"); - - var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); - await File.WriteAllTextAsync(configPath, - "packages:\n" - + " - name: Microsoft.WindowsAppSDK\n" - + " version: 1.8.39\n" - + "jsBindings:\n" - + " output: managed-old\n" - + " lang: js\n" - + " packages:\n" - + " - Microsoft.WindowsAppSDK.AI\n"); - - File.WriteAllText( - Path.Combine(_tempDirectory.FullName, "package.json"), - """{"name":"app","version":"1.0.0","dependencies":{}}"""); - - _fakeCodegen.FailWith = new InvalidOperationException("simulated codegen failure"); - - var addCmd = GetRequiredService(); - var exit = await ParseAndInvokeWithCaptureAsync(addCmd, - new[] { _tempDirectory.FullName, "--force", "--output", "fresh-out" }); - - Assert.AreNotEqual(0, exit, "Codegen failure must surface non-zero."); - Assert.IsTrue(File.Exists(Path.Combine(managedOld, "Uri.js")), - "Codegen failure must NOT wipe the previous bindings."); - } - - [TestMethod] - public async Task AddJsBindings_AdditionalWinmds_FlowsIntoCodegenEmitSet() - { - // additionalWinmds entries must reach codegen as user-additional emit. - var aiWinmd = Path.Combine(_tempDirectory.FullName, "fake-cache", - "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); - SetUpWorkspaceWithLockfile( - lockfilePackages: new[] - { - ("Microsoft.WindowsAppSDK.AI", "1.8.39", "emit", new[] { aiWinmd }), - }); - - // Seed a real vendor winmd file referenced by additionalWinmds. - var vendorWinmd = Path.Combine(_tempDirectory.FullName, "vendor", "MyCo.Foo.winmd"); - Directory.CreateDirectory(Path.GetDirectoryName(vendorWinmd)!); - File.WriteAllText(vendorWinmd, "stub"); - - var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); - await File.WriteAllTextAsync(configPath, - "packages:\n" - + " - name: Microsoft.WindowsAppSDK\n" - + " version: 1.8.39\n" - + "jsBindings:\n" - + " output: bindings/winrt\n" - + " lang: js\n" - + " packages:\n" - + " - Microsoft.WindowsAppSDK.AI\n" - + " additionalWinmds:\n" - + " - vendor/MyCo.Foo.winmd\n"); - - File.WriteAllText( - Path.Combine(_tempDirectory.FullName, "package.json"), - """{"name":"app","version":"1.0.0","dependencies":{}}"""); - - var addCmd = GetRequiredService(); - var exit = await ParseAndInvokeWithCaptureAsync(addCmd, new[] { _tempDirectory.FullName, "--force" }); - - Assert.AreEqual(0, exit, $"Expected success; stderr: {ConsoleStdErr}"); - Assert.AreEqual(1, _fakeCodegen.Calls.Count); - var call = _fakeCodegen.Calls[0]; - Assert.IsTrue(call.UserAdditionalWinmds.Any(p => p.EndsWith("MyCo.Foo.winmd", StringComparison.OrdinalIgnoreCase)), - $"additionalWinmds must surface to codegen via UserAdditionalWinmds. Got: {string.Join(", ", call.UserAdditionalWinmds)}"); - } - - [TestMethod] - public async Task AddJsBindings_AdditionalRefs_FlowsIntoCodegenRefSet() - { - // jsBindings.additionalRefs entries must flow via UserAdditionalRefs. - var aiWinmd = Path.Combine(_tempDirectory.FullName, "fake-cache", - "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); - SetUpWorkspaceWithLockfile( - lockfilePackages: new[] - { - ("Microsoft.WindowsAppSDK.AI", "1.8.39", "emit", new[] { aiWinmd }), - }); - - var vendorRefWinmd = Path.Combine(_tempDirectory.FullName, "vendor", "BigSDK.winmd"); - Directory.CreateDirectory(Path.GetDirectoryName(vendorRefWinmd)!); - File.WriteAllText(vendorRefWinmd, "stub"); - - var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); - await File.WriteAllTextAsync(configPath, - "packages:\n" - + " - name: Microsoft.WindowsAppSDK\n" - + " version: 1.8.39\n" - + "jsBindings:\n" - + " output: bindings/winrt\n" - + " lang: js\n" - + " packages:\n" - + " - Microsoft.WindowsAppSDK.AI\n" - + " additionalRefs:\n" - + " - vendor/BigSDK.winmd\n"); - - File.WriteAllText( - Path.Combine(_tempDirectory.FullName, "package.json"), - """{"name":"app","version":"1.0.0","dependencies":{}}"""); - - var addCmd = GetRequiredService(); - var exit = await ParseAndInvokeWithCaptureAsync(addCmd, new[] { _tempDirectory.FullName, "--force" }); - - Assert.AreEqual(0, exit, $"Expected success; stderr: {ConsoleStdErr}"); - var call = _fakeCodegen.Calls[0]; - Assert.IsTrue(call.UserAdditionalRefs.Any(p => p.EndsWith("BigSDK.winmd", StringComparison.OrdinalIgnoreCase)), - $"additionalRefs must surface to codegen via UserAdditionalRefs. Got: {string.Join(", ", call.UserAdditionalRefs)}"); - } - - // UNC paths in additionalWinmds must be rejected without probing - // (FileInfo.Exists on a UNC triggers SMB / NTLM leak). - [TestMethod] - public async Task AddJsBindings_AdditionalWinmds_UncEntry_Rejected_NotProbedNotPassedToCodegen() - { - var aiWinmd = Path.Combine(_tempDirectory.FullName, "fake-cache", - "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); - SetUpWorkspaceWithLockfile( - lockfilePackages: new[] - { - ("Microsoft.WindowsAppSDK.AI", "1.8.39", "emit", new[] { aiWinmd }), - }); - - // Yaml has a benign local entry + a UNC entry; only the benign one - // should reach codegen. - var legitWinmd = Path.Combine(_tempDirectory.FullName, "vendor", "Legit.winmd"); - Directory.CreateDirectory(Path.GetDirectoryName(legitWinmd)!); - File.WriteAllText(legitWinmd, "stub"); - - // RFC 2606 `.invalid` TLD — never resolves even if our guard fails. - var uncWinmd = @"\\nonexistent-attacker.invalid\share\evil.winmd"; - - File.WriteAllText(Path.Combine(_tempDirectory.FullName, "winapp.yaml"), - "packages:\n" - + " - name: Microsoft.WindowsAppSDK\n" - + " version: 1.8.39\n" - + "jsBindings:\n" - + " output: bindings/winrt\n" - + " lang: js\n" - + " packages:\n" - + " - Microsoft.WindowsAppSDK.AI\n" - + " additionalWinmds:\n" - + " - vendor/Legit.winmd\n" - + $" - {uncWinmd.Replace("\\", "\\\\")}\n"); - - File.WriteAllText( - Path.Combine(_tempDirectory.FullName, "package.json"), - """{"name":"app","version":"1.0.0","dependencies":{}}"""); - - var addCmd = GetRequiredService(); - - // Cap runtime: a failed guard means a 20s+ SMB timeout per UNC entry. - var sw = System.Diagnostics.Stopwatch.StartNew(); - var exit = await ParseAndInvokeWithCaptureAsync(addCmd, new[] { _tempDirectory.FullName, "--force" }); - sw.Stop(); - - Assert.AreEqual(0, exit, $"Expected success; stderr: {ConsoleStdErr}"); - Assert.IsTrue(sw.ElapsedMilliseconds < 10_000, - $"UNC entry must be rejected without SMB probe (took {sw.ElapsedMilliseconds}ms; " - + "anything >5s suggests we did probe)."); - - Assert.AreEqual(1, _fakeCodegen.Calls.Count); - var call = _fakeCodegen.Calls[0]; - Assert.IsTrue( - call.UserAdditionalWinmds.Any(p => p.EndsWith("Legit.winmd", StringComparison.OrdinalIgnoreCase)), - "Legit local entry must still reach codegen."); - Assert.IsFalse( - call.UserAdditionalWinmds.Any(p => p.Contains("nonexistent-attacker.invalid", StringComparison.OrdinalIgnoreCase)), - $"UNC entry MUST be dropped — codegen received: {string.Join(", ", call.UserAdditionalWinmds)}"); - } - - // extraTypes-only: additionalRefs + extraTypes, no bulk emit. - [TestMethod] - public async Task AddJsBindings_ExtraTypesOnlyWithAdditionalRefs_Succeeds() - { - // jsBindings declares only additionalRefs + extraTypes (no packages, - // no additionalWinmds). - var vendorWinmd = Path.Combine(_tempDirectory.FullName, "vendor", "Vendor.SDK.winmd"); - Directory.CreateDirectory(Path.GetDirectoryName(vendorWinmd)!); - File.WriteAllText(vendorWinmd, "stub"); - - // Empty lockfile (no emit packages) — reaches the empty-emit guard. - var configForHash = "packages:\n - name: Microsoft.WindowsAppSDK\n version: 1.8.39\n"; - File.WriteAllText(Path.Combine(_tempDirectory.FullName, "winapp.yaml"), configForHash); - var loadedConfig = new ConfigService(new CurrentDirectoryProvider(_tempDirectory.FullName)) - { - ConfigPath = new FileInfo(Path.Combine(_tempDirectory.FullName, "winapp.yaml")), - }.Load(); - var hash = YamlPackagesHasher.Compute(loadedConfig.Packages); - - var winappDir = _tempDirectory.CreateSubdirectory(".winapp"); - var lockfile = new WinmdsLockfile - { - Schema = WinmdsLockfile.CurrentSchema, - GeneratedAt = DateTimeOffset.UtcNow.ToString("O"), - NugetCacheDir = _tempDirectory.FullName, - YamlPackagesHash = hash, - // No emit/refOnly packages — the only way to feed metadata is - // via additionalRefs in the yaml below. - Packages = new List(), - }; - var json = System.Text.Json.JsonSerializer.Serialize( - lockfile, WinmdsLockfileJsonContext.Default.WinmdsLockfile); - File.WriteAllText(Path.Combine(winappDir.FullName, "winmds.lock.json"), json); - - // Rewrite the yaml with jsBindings: additionalRefs + extraTypes only. - File.WriteAllText(Path.Combine(_tempDirectory.FullName, "winapp.yaml"), - "packages:\n" - + " - name: Microsoft.WindowsAppSDK\n" - + " version: 1.8.39\n" - + "jsBindings:\n" - + " output: bindings/winrt\n" - + " lang: js\n" - + " additionalRefs:\n" - + " - vendor/Vendor.SDK.winmd\n" - + " extraTypes:\n" - + " - namespace: Vendor.SDK.Camera\n" - + " classes:\n" - + " - Lens\n" - + " - Sensor\n"); - - File.WriteAllText( - Path.Combine(_tempDirectory.FullName, "package.json"), - """{"name":"app","version":"1.0.0","dependencies":{}}"""); - - var addCmd = GetRequiredService(); - var exit = await ParseAndInvokeWithCaptureAsync(addCmd, new[] { _tempDirectory.FullName, "--force" }); - - Assert.AreEqual(0, exit, - $"extraTypes-only cherry-pick workflow must succeed. stderr: {ConsoleStdErr}"); - Assert.AreEqual(1, _fakeCodegen.Calls.Count, "Codegen must be invoked."); - var call = _fakeCodegen.Calls[0]; - Assert.AreEqual(0, call.EmitWinmds.Length, - "extraTypes-only flow has no bulk emit set — codegen sees zero emit winmds."); - Assert.IsTrue(call.UserAdditionalRefs.Any(p => p.EndsWith("Vendor.SDK.winmd", StringComparison.OrdinalIgnoreCase)), - "Vendor ref winmd must reach codegen as a ref."); - Assert.AreEqual(1, call.Config.ExtraTypes.Count, "extraTypes must be passed through."); - Assert.AreEqual("Vendor.SDK.Camera", call.Config.ExtraTypes[0].Namespace); - CollectionAssert.AreEquivalent( - _arr00, - call.Config.ExtraTypes[0].Classes.ToList()); - } - - // Only-malformed extraTypes (blank ns / empty classes) must fail - // before codegen — otherwise we'd return success with zero bindings. - [TestMethod] - public async Task AddJsBindings_ExtraTypesOnlyWithMalformedEntries_FailsBeforeCodegen() - { - var vendorWinmd = Path.Combine(_tempDirectory.FullName, "vendor", "Vendor.SDK.winmd"); - Directory.CreateDirectory(Path.GetDirectoryName(vendorWinmd)!); - File.WriteAllText(vendorWinmd, "stub"); - - var configForHash = "packages:\n - name: Microsoft.WindowsAppSDK\n version: 1.8.39\n"; - File.WriteAllText(Path.Combine(_tempDirectory.FullName, "winapp.yaml"), configForHash); - var loadedConfig = new ConfigService(new CurrentDirectoryProvider(_tempDirectory.FullName)) - { - ConfigPath = new FileInfo(Path.Combine(_tempDirectory.FullName, "winapp.yaml")), - }.Load(); - var hash = YamlPackagesHasher.Compute(loadedConfig.Packages); - - var winappDir = _tempDirectory.CreateSubdirectory(".winapp"); - var lockfile = new WinmdsLockfile - { - Schema = WinmdsLockfile.CurrentSchema, - GeneratedAt = DateTimeOffset.UtcNow.ToString("O"), - NugetCacheDir = _tempDirectory.FullName, - YamlPackagesHash = hash, - Packages = new List(), - }; - var json = System.Text.Json.JsonSerializer.Serialize( - lockfile, WinmdsLockfileJsonContext.Default.WinmdsLockfile); - File.WriteAllText(Path.Combine(winappDir.FullName, "winmds.lock.json"), json); - - // Two malformed entries (blank ns + empty classes) → codegen would - // silently skip both. - File.WriteAllText(Path.Combine(_tempDirectory.FullName, "winapp.yaml"), - "packages:\n" - + " - name: Microsoft.WindowsAppSDK\n" - + " version: 1.8.39\n" - + "jsBindings:\n" - + " output: bindings/winrt\n" - + " lang: js\n" - + " additionalRefs:\n" - + " - vendor/Vendor.SDK.winmd\n" - + " extraTypes:\n" - + " - namespace: ''\n" - + " classes:\n" - + " - Lens\n" - + " - namespace: Vendor.SDK.Camera\n" - + " classes: []\n"); - - File.WriteAllText( - Path.Combine(_tempDirectory.FullName, "package.json"), - """{"name":"app","version":"1.0.0","dependencies":{}}"""); - - var addCmd = GetRequiredService(); - var exit = await ParseAndInvokeWithCaptureAsync(addCmd, new[] { _tempDirectory.FullName, "--force" }); - - Assert.AreNotEqual(0, exit, - "Malformed-only extraTypes must fail rather than silently produce zero bindings."); - Assert.AreEqual(0, _fakeCodegen.Calls.Count, - "Codegen MUST NOT be invoked when all extraTypes would be skipped."); - } - - // Companion to the additionalWinmds UNC test: refs flow through the - // same lockfile-bypass route, so UNC entries must also be dropped. - [TestMethod] - public async Task AddJsBindings_AdditionalRefs_UncEntry_Rejected_NotProbedNotPassedToCodegen() - { - var aiWinmd = Path.Combine(_tempDirectory.FullName, "fake-cache", - "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); - SetUpWorkspaceWithLockfile( - lockfilePackages: new[] - { - ("Microsoft.WindowsAppSDK.AI", "1.8.39", "emit", new[] { aiWinmd }), - }); - - // Yaml has a benign local ref + a UNC ref; only the benign reaches codegen. - var legitRef = Path.Combine(_tempDirectory.FullName, "vendor", "Legit.Ref.winmd"); - Directory.CreateDirectory(Path.GetDirectoryName(legitRef)!); - File.WriteAllText(legitRef, "stub"); - - // RFC 2606 reserved TLD — never resolves, even if our guard fails. - var uncRef = @"\\nonexistent-attacker.invalid\share\evil.ref.winmd"; - - File.WriteAllText(Path.Combine(_tempDirectory.FullName, "winapp.yaml"), - "packages:\n" - + " - name: Microsoft.WindowsAppSDK\n" - + " version: 1.8.39\n" - + "jsBindings:\n" - + " output: bindings/winrt\n" - + " lang: js\n" - + " packages:\n" - + " - Microsoft.WindowsAppSDK.AI\n" - + " additionalRefs:\n" - + " - vendor/Legit.Ref.winmd\n" - + $" - {uncRef.Replace("\\", "\\\\")}\n"); - - File.WriteAllText( - Path.Combine(_tempDirectory.FullName, "package.json"), - """{"name":"app","version":"1.0.0","dependencies":{}}"""); - - var addCmd = GetRequiredService(); - - var sw = System.Diagnostics.Stopwatch.StartNew(); - var exit = await ParseAndInvokeWithCaptureAsync(addCmd, new[] { _tempDirectory.FullName, "--force" }); - sw.Stop(); - - Assert.AreEqual(0, exit, $"Expected success; stderr: {ConsoleStdErr}"); - Assert.IsTrue(sw.ElapsedMilliseconds < 10_000, - $"UNC ref must be rejected without SMB probe (took {sw.ElapsedMilliseconds}ms; " - + "anything >5s suggests we did probe)."); - - Assert.AreEqual(1, _fakeCodegen.Calls.Count); - var call = _fakeCodegen.Calls[0]; - Assert.IsTrue( - call.UserAdditionalRefs.Any(p => p.EndsWith("Legit.Ref.winmd", StringComparison.OrdinalIgnoreCase)), - "Legit local ref must still reach codegen."); - Assert.IsFalse( - call.UserAdditionalRefs.Any(p => p.Contains("nonexistent-attacker.invalid", StringComparison.OrdinalIgnoreCase)), - $"UNC ref MUST be dropped — codegen received: {string.Join(", ", call.UserAdditionalRefs)}"); - } - - // M1 (round-6): absolute paths outside the workspace must be accepted. - // docs/js-bindings.md:85,216,400 advertise absolute-path support; pre-r6 - // the reparse-point guard used workspaceDir as boundary and silently - // dropped any out-of-workspace absolute path. - [TestMethod] - public async Task AddJsBindings_AdditionalWinmds_AbsolutePathOutsideWorkspace_ReachesCodegen() - { - var aiWinmd = Path.Combine(_tempDirectory.FullName, "fake-cache", - "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); - SetUpWorkspaceWithLockfile( - lockfilePackages: new[] - { - ("Microsoft.WindowsAppSDK.AI", "1.8.39", "emit", new[] { aiWinmd }), - }); - - // Stage a vendor winmd in a SIBLING directory (outside workspace). - var siblingDir = new DirectoryInfo(Path.Combine( - Path.GetTempPath(), - string.Concat("winapp-r6-abs-".AsSpan(), Guid.NewGuid().ToString("N").AsSpan(0, 8)))); - siblingDir.Create(); - var externalWinmd = Path.Combine(siblingDir.FullName, "External.winmd"); - File.WriteAllText(externalWinmd, "stub"); - - try - { - File.WriteAllText(Path.Combine(_tempDirectory.FullName, "winapp.yaml"), - "packages:\n" - + " - name: Microsoft.WindowsAppSDK\n" - + " version: 1.8.39\n" - + "jsBindings:\n" - + " output: bindings/winrt\n" - + " lang: js\n" - + " packages:\n" - + " - Microsoft.WindowsAppSDK.AI\n" - + " additionalWinmds:\n" - + $" - {externalWinmd.Replace("\\", "\\\\")}\n"); - - File.WriteAllText( - Path.Combine(_tempDirectory.FullName, "package.json"), - """{"name":"app","version":"1.0.0","dependencies":{}}"""); - - var addCmd = GetRequiredService(); - var exit = await ParseAndInvokeWithCaptureAsync(addCmd, new[] { _tempDirectory.FullName, "--force" }); - - Assert.AreEqual(0, exit, $"Expected success; stderr: {ConsoleStdErr}"); - Assert.AreEqual(1, _fakeCodegen.Calls.Count); - var call = _fakeCodegen.Calls[0]; - Assert.IsTrue( - call.UserAdditionalWinmds.Any(p => p.EndsWith("External.winmd", StringComparison.OrdinalIgnoreCase)), - $"Absolute path outside workspace must reach codegen. Got: {string.Join(", ", call.UserAdditionalWinmds)}"); - } - finally - { - try { siblingDir.Delete(recursive: true); } catch { /* best-effort cleanup */ } - } - } - - // M1 (round-6) companion: absolute additionalRefs must also be accepted - // (same boundary fix; both fields flow through ResolveAdditionalWinmds). - [TestMethod] - public async Task AddJsBindings_AdditionalRefs_AbsolutePathOutsideWorkspace_ReachesCodegen() - { - var aiWinmd = Path.Combine(_tempDirectory.FullName, "fake-cache", - "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); - SetUpWorkspaceWithLockfile( - lockfilePackages: new[] - { - ("Microsoft.WindowsAppSDK.AI", "1.8.39", "emit", new[] { aiWinmd }), - }); - - var siblingDir = new DirectoryInfo(Path.Combine( - Path.GetTempPath(), - string.Concat("winapp-r6-absref-".AsSpan(), Guid.NewGuid().ToString("N").AsSpan(0, 8)))); - siblingDir.Create(); - var externalRef = Path.Combine(siblingDir.FullName, "External.Ref.winmd"); - File.WriteAllText(externalRef, "stub"); - - try - { - File.WriteAllText(Path.Combine(_tempDirectory.FullName, "winapp.yaml"), - "packages:\n" - + " - name: Microsoft.WindowsAppSDK\n" - + " version: 1.8.39\n" - + "jsBindings:\n" - + " output: bindings/winrt\n" - + " lang: js\n" - + " packages:\n" - + " - Microsoft.WindowsAppSDK.AI\n" - + " additionalRefs:\n" - + $" - {externalRef.Replace("\\", "\\\\")}\n"); - - File.WriteAllText( - Path.Combine(_tempDirectory.FullName, "package.json"), - """{"name":"app","version":"1.0.0","dependencies":{}}"""); - - var addCmd = GetRequiredService(); - var exit = await ParseAndInvokeWithCaptureAsync(addCmd, new[] { _tempDirectory.FullName, "--force" }); - - Assert.AreEqual(0, exit, $"Expected success; stderr: {ConsoleStdErr}"); - Assert.AreEqual(1, _fakeCodegen.Calls.Count); - var call = _fakeCodegen.Calls[0]; - Assert.IsTrue( - call.UserAdditionalRefs.Any(p => p.EndsWith("External.Ref.winmd", StringComparison.OrdinalIgnoreCase)), - $"Absolute ref outside workspace must reach codegen. Got: {string.Join(", ", call.UserAdditionalRefs)}"); - } - finally - { - try { siblingDir.Delete(recursive: true); } catch { /* best-effort cleanup */ } - } - } - - // M8: when old/new output dirs nest (either direction), cleanup must - // be skipped or wiping old would erase the freshly generated bindings. - [TestMethod] - public async Task AddJsBindings_OutputChange_NewNestedInsideOld_CleanupSkipped() - { - // old = "bindings", new = "bindings/winrt" (child of old). - var aiWinmd = Path.Combine(_tempDirectory.FullName, "fake-cache", - "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); - SetUpWorkspaceWithLockfile( - lockfilePackages: new[] - { - ("Microsoft.WindowsAppSDK.AI", "1.8.39", "emit", new[] { aiWinmd }), - }); - - // Marker-gated old dir would normally be wiped — overlap guard skips it. - var oldDir = Path.Combine(_tempDirectory.FullName, "bindings"); - Directory.CreateDirectory(oldDir); - File.WriteAllText(Path.Combine(oldDir, "stale.js"), "// old"); - File.WriteAllText(Path.Combine(oldDir, DynWinrtCodegenService.ManagedMarkerFileName), "# managed"); - - var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); - await File.WriteAllTextAsync(configPath, - "packages:\n" - + " - name: Microsoft.WindowsAppSDK\n" - + " version: 1.8.39\n" - + "jsBindings:\n" - + " output: bindings\n" - + " lang: js\n" - + " packages:\n" - + " - Microsoft.WindowsAppSDK.AI\n"); - - File.WriteAllText( - Path.Combine(_tempDirectory.FullName, "package.json"), - """{"name":"app","version":"1.0.0","dependencies":{}}"""); - - var addCmd = GetRequiredService(); - var exit = await ParseAndInvokeWithCaptureAsync(addCmd, - new[] { _tempDirectory.FullName, "--force", "--output", "bindings/winrt" }); - - Assert.AreEqual(0, exit, $"Expected success; stderr: {ConsoleStdErr}"); - - var newFile = Path.Combine(_tempDirectory.FullName, "bindings", "winrt", "index.js"); - Assert.IsTrue(File.Exists(newFile), - "Freshly generated bindings MUST survive — overlap cleanup must not delete them."); - } - - [TestMethod] - public async Task AddJsBindings_OutputChange_OldNestedInsideNew_CleanupSkipped() - { - // old = "bindings/winrt" (child), new = "bindings" (parent). - var aiWinmd = Path.Combine(_tempDirectory.FullName, "fake-cache", - "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); - SetUpWorkspaceWithLockfile( - lockfilePackages: new[] - { - ("Microsoft.WindowsAppSDK.AI", "1.8.39", "emit", new[] { aiWinmd }), - }); - - var oldDir = Path.Combine(_tempDirectory.FullName, "bindings", "winrt"); - Directory.CreateDirectory(oldDir); - File.WriteAllText(Path.Combine(oldDir, "stale.js"), "// old"); - File.WriteAllText(Path.Combine(oldDir, DynWinrtCodegenService.ManagedMarkerFileName), "# managed"); - - var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); - await File.WriteAllTextAsync(configPath, - "packages:\n" - + " - name: Microsoft.WindowsAppSDK\n" - + " version: 1.8.39\n" - + "jsBindings:\n" - + " output: bindings/winrt\n" - + " lang: js\n" - + " packages:\n" - + " - Microsoft.WindowsAppSDK.AI\n"); - - File.WriteAllText( - Path.Combine(_tempDirectory.FullName, "package.json"), - """{"name":"app","version":"1.0.0","dependencies":{}}"""); - - var addCmd = GetRequiredService(); - var exit = await ParseAndInvokeWithCaptureAsync(addCmd, - new[] { _tempDirectory.FullName, "--force", "--output", "bindings" }); - - Assert.AreEqual(0, exit, $"Expected success; stderr: {ConsoleStdErr}"); - - var newFile = Path.Combine(_tempDirectory.FullName, "bindings", "index.js"); - Assert.IsTrue(File.Exists(newFile), - "Freshly generated bindings MUST survive at the new (parent) location."); - } -} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/GenerateJsBindingsCommandTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/GenerateJsBindingsCommandTests.cs deleted file mode 100644 index 48237ea5..00000000 --- a/src/winapp-CLI/WinApp.Cli.Tests/GenerateJsBindingsCommandTests.cs +++ /dev/null @@ -1,162 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using WinApp.Cli.Commands; - -namespace WinApp.Cli.Tests; - -// Tests for `node jsbindings generate` — read-only codegen against the -// existing winapp.yaml. Mirrors AddJsBindingsCommandTests structure. -// Codegen result is not asserted (no real NuGet cache); we assert yaml -// is NOT mutated and the npm-shim gate is enforced. -[TestClass] -[DoNotParallelize] -public class GenerateJsBindingsCommandTests : BaseCommandTests -{ - private string? _savedCaller; - - [TestInitialize] - public void TestSetup() - { - _savedCaller = Environment.GetEnvironmentVariable("WINAPP_CLI_CALLER"); - Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); - } - - [TestCleanup] - public void TestTeardown() - { - Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", _savedCaller); - } - - private async Task<(string ConfigPath, string Content)> WriteYamlWithJsBindingsAsync(string output = "bindings/winrt") - { - var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); - var content = - "packages:\n" - + " - name: Microsoft.WindowsAppSDK\n" - + " version: 1.8.39\n" - + "jsBindings:\n" - + $" output: {output}\n" - + " lang: js\n"; - await File.WriteAllTextAsync(configPath, content); - return (configPath, content); - } - - [TestMethod] - public async Task Generate_WithoutNpmCaller_ExitsWithActionableError() - { - Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", null); - await WriteYamlWithJsBindingsAsync(); - - var cmd = GetRequiredService(); - var args = new[] { _tempDirectory.FullName }; - - var exitCode = await ParseAndInvokeWithCaptureAsync(cmd, args); - - Assert.AreEqual(1, exitCode); - var stderr = ConsoleStdErr.ToString(); - StringAssert.Contains(stderr, "'node jsbindings generate' requires the @microsoft/winappcli npm package"); - StringAssert.Contains(stderr, "npx winapp node jsbindings generate"); - } - - [TestMethod] - public async Task Generate_NoYaml_ReturnsErrorWithInitHint() - { - // No yaml at all → tell the user to init first. - var cmd = GetRequiredService(); - var args = new[] { _tempDirectory.FullName }; - - var exitCode = await ParseAndInvokeWithCaptureAsync(cmd, args); - - Assert.AreEqual(1, exitCode); - var stderr = ConsoleStdErr.ToString(); - StringAssert.Contains(stderr, "winapp.yaml not found"); - } - - [TestMethod] - public async Task Generate_YamlWithoutJsBindingsBlock_FailsWithAddHint() - { - // yaml exists but has no jsBindings: block — point the user at - // `node jsbindings add` to declare one first. - var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); - await File.WriteAllTextAsync(configPath, - "packages:\n" - + " - name: Microsoft.WindowsAppSDK\n" - + " version: 1.8.39\n"); - var originalContent = await File.ReadAllTextAsync(configPath); - - var cmd = GetRequiredService(); - var args = new[] { _tempDirectory.FullName }; - - var exitCode = await ParseAndInvokeWithCaptureAsync(cmd, args); - - Assert.AreEqual(1, exitCode); - var stderr = ConsoleStdErr.ToString(); - StringAssert.Contains(stderr, "No jsBindings: block", - "Error must call out the missing block."); - StringAssert.Contains(stderr, "node jsbindings add", - "Error must point users at the add command."); - - var after = await File.ReadAllTextAsync(configPath); - Assert.AreEqual(originalContent, after, - "Yaml must remain byte-identical when generate refuses."); - } - - [TestMethod] - public async Task Generate_WithExistingJsBindings_DoesNotMutateYaml() - { - // Happy-ish path: yaml has jsBindings block. Codegen will fail with - // "no winmds" (no NuGet cache in test env), but we only assert - // that the yaml is NOT mutated regardless of codegen outcome. - var (configPath, originalContent) = await WriteYamlWithJsBindingsAsync("generated-js"); - - var cmd = GetRequiredService(); - var args = new[] { _tempDirectory.FullName }; - - await ParseAndInvokeWithCaptureAsync(cmd, args); - - var after = await File.ReadAllTextAsync(configPath); - Assert.AreEqual(originalContent, after, - "generate is read-only on yaml — file must be byte-identical."); - } - - // M3 (round-6): `node jsbindings generate` is documented as read-only. - // That contract covers package.json too — re-adding @microsoft/dynwinrt - // on every regen would silently un-do a deliberate user removal. - [TestMethod] - public async Task Generate_WithExistingJsBindings_DoesNotMutatePackageJson() - { - await WriteYamlWithJsBindingsAsync("generated-js"); - - // package.json WITHOUT @microsoft/dynwinrt in dependencies — the - // user has deliberately removed it, and generate must respect that. - var packageJsonPath = Path.Combine(_tempDirectory.FullName, "package.json"); - const string originalPkg = """{"name":"app","version":"1.0.0","dependencies":{"react":"18.0.0"}}"""; - await File.WriteAllTextAsync(packageJsonPath, originalPkg); - - var cmd = GetRequiredService(); - var args = new[] { _tempDirectory.FullName }; - - await ParseAndInvokeWithCaptureAsync(cmd, args); - - var after = await File.ReadAllTextAsync(packageJsonPath); - Assert.AreEqual(originalPkg, after, - "generate must NOT inject @microsoft/dynwinrt into package.json — that's add's job."); - } - - [TestMethod] - public async Task Generate_RoutesViaWinAppRootCommand() - { - // Verify the actual command tree exposes `node jsbindings generate`. - await WriteYamlWithJsBindingsAsync(); - - var rootCmd = GetRequiredService(); - var args = new[] { "node", "jsbindings", "generate", _tempDirectory.FullName }; - - var parseResult = rootCmd.Parse(args); - Assert.AreEqual(0, parseResult.Errors.Count, - "Parse errors: " + string.Join("; ", parseResult.Errors.Select(e => e.Message))); - Assert.IsInstanceOfType(parseResult.CommandResult.Command, - "`node jsbindings generate` must route to GenerateJsBindingsCommand."); - } -} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InitCommandTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InitCommandTests.cs index 2fdd06a4..c36d47ef 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InitCommandTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InitCommandTests.cs @@ -140,12 +140,13 @@ public async Task InitCommand_DoesNotGenerateCertificate() } } -// --js-bindings flag gating — only allowed from the npm shim -// (WINAPP_CLI_CALLER=nodejs-package). [DoNotParallelize] because tests -// mutate that process-wide env var. +// Npm-caller bindings prompt — only fires under WINAPP_CLI_CALLER=nodejs-package, +// and only when there's no existing jsBindings: block to preserve. Default Both +// under --use-defaults, no prompt for native callers. [DoNotParallelize] because +// tests mutate that process-wide env var. [TestClass] [DoNotParallelize] -public class InitCommandJsBindingsTests : BaseCommandTests +public class InitCommandBindingsPromptTests : BaseCommandTests { private string? _savedCaller; @@ -162,394 +163,132 @@ public void TestTeardown() } [TestMethod] - public async Task InitCommand_WithJsBindingsAndWingetCaller_ExitsWithActionableError() + public async Task InitCommand_NpmCallerWithUseDefaults_AddsJsBindingsBlock() { - // Arrange — simulate winget invocation: env var unset. - Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", null); - - var initCommand = GetRequiredService(); - var args = new[] { _tempDirectory.FullName, "--config-only", "--js-bindings" }; - - // Act - var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); - - // Assert - Assert.AreEqual(1, exitCode, "winget caller passing --js-bindings should exit with code 1"); - - var stderr = ConsoleStdErr.ToString(); - StringAssert.Contains(stderr, "--js-bindings requires the @microsoft/winappcli npm package", - "Error must name the flag and the required package"); - StringAssert.Contains(stderr, "npm i -D @microsoft/winappcli", - "Error must include the recovery command"); - StringAssert.Contains(stderr, "npx winapp init --js-bindings", - "Error must show the post-install invocation"); - - // No yaml should have been written — we bailed before InitializeConfigurationAsync ran. - var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); - Assert.IsFalse(File.Exists(configPath), "winapp.yaml should not be written when init bails early"); - } - - [TestMethod] - public async Task InitCommand_WithJsBindingsAndVscodeCaller_ExitsWithActionableError() - { - // Arrange — VSCode extension is also a Node host but is not the npm - // shim that ships dynwinrt-codegen as a transitive dep, so it is - // explicitly NOT allowed (matches design choice 3=b). - Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "vscode-extension"); - - var initCommand = GetRequiredService(); - var args = new[] { _tempDirectory.FullName, "--config-only", "--js-bindings" }; - - var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); - - Assert.AreEqual(1, exitCode, "vscode-extension caller passing --js-bindings should exit 1"); - StringAssert.Contains(ConsoleStdErr.ToString(), "--js-bindings requires the @microsoft/winappcli npm package"); - } - - [TestMethod] - public async Task InitCommand_WithJsBindingsAndNpmCaller_AddsJsBindingsBlockToConfig() - { - // Arrange — simulate npm shim invocation. Pre-create a package.json in - // the workspace so we exercise the v1.2 happy-path that adds - // @microsoft/dynwinrt to dependencies. - Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); - var packageJsonPath = Path.Combine(_tempDirectory.FullName, "package.json"); - await File.WriteAllTextAsync(packageJsonPath, - "{\n \"name\": \"my-app\",\n \"version\": \"1.0.0\"\n}\n"); - - var initCommand = GetRequiredService(); - var args = new[] { _tempDirectory.FullName, "--config-only", "--js-bindings" }; - - // Act - var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); - - // Assert - Assert.AreEqual(0, exitCode, "npm caller with --js-bindings should succeed"); - - var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); - Assert.IsTrue(File.Exists(configPath), $"winapp.yaml should be written at {configPath}"); - - var configContent = await File.ReadAllTextAsync(configPath); - StringAssert.Contains(configContent, "packages:", "Standard packages section should still be written"); - StringAssert.Contains(configContent, "jsBindings:", "jsBindings: block should be injected by --js-bindings"); - StringAssert.Contains(configContent, "lang: js", "Default lang=js should be persisted"); - // XAML denylisting now lives in the codegen, not yaml. - StringAssert.DoesNotMatch(configContent, new System.Text.RegularExpressions.Regex(@"excludeNamespacePrefixes\s*:"), - "Default jsBindings yaml must not emit the deprecated excludeNamespacePrefixes block."); - - // @microsoft/dynwinrt must be a runtime dep so `npm ci --omit=dev` works. - var packageJsonContent = await File.ReadAllTextAsync(packageJsonPath); - StringAssert.Contains(packageJsonContent, "@microsoft/dynwinrt", - "package.json should now contain @microsoft/dynwinrt"); - StringAssert.Contains(packageJsonContent, "0.0.0-test", - "Pinned version from FakeNpmWrapperVersionProvider should be written"); - StringAssert.Contains(packageJsonContent, "\"dependencies\"", - "Dependency must be added under dependencies (not devDependencies)"); - } - - [TestMethod] - public async Task InitCommand_WithJsBindingsAndNpmCaller_NoPackageJson_StillSucceeds() - { - // No package.json: don't fail, don't synthesize one — just skip the - // dep edit with a warning. - Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); - - var initCommand = GetRequiredService(); - var args = new[] { _tempDirectory.FullName, "--config-only", "--js-bindings" }; - - var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); - - Assert.AreEqual(0, exitCode, "Missing package.json must not fail init"); - var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); - Assert.IsTrue(File.Exists(configPath), "winapp.yaml should still be written"); - StringAssert.Contains(await File.ReadAllTextAsync(configPath), "jsBindings:"); - Assert.IsFalse( - File.Exists(Path.Combine(_tempDirectory.FullName, "package.json")), - "We must not synthesize a package.json on the user's behalf"); - } - - [TestMethod] - public async Task InitCommand_WithoutJsBindingsFlag_DoesNotAddJsBindingsBlock() - { - // Arrange — even with npm caller set, omitting the flag must not - // inject jsBindings (design choice 2=a: opt-in only, no auto-detect). + // Default for npm caller under --use-defaults is "Both", so jsBindings + // must land in winapp.yaml without any explicit flag. Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); + await File.WriteAllTextAsync( + Path.Combine(_tempDirectory.FullName, "package.json"), + """{"name":"my-app","version":"1.0.0"}"""); var initCommand = GetRequiredService(); - var args = new[] { _tempDirectory.FullName, "--config-only" }; - + var args = new[] { _tempDirectory.FullName, "--config-only", "--use-defaults" }; var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); Assert.AreEqual(0, exitCode); - var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); - var configContent = await File.ReadAllTextAsync(configPath); - Assert.DoesNotContain("jsBindings:", configContent, - "Without --js-bindings, no jsBindings block should be added even when npm-invoked"); + var configContent = await File.ReadAllTextAsync(Path.Combine(_tempDirectory.FullName, "winapp.yaml")); + StringAssert.Contains(configContent, "jsBindings:", + "npm caller + --use-defaults defaults to Both, so jsBindings: must be added"); + Assert.IsFalse(configContent.Contains("cppProjections: false"), + "Both mode keeps cppProjections at default (true); explicit false should not be written"); } - // ------------------------------------------------------------------------- - // Q4: --js-bindings-output / --js-bindings-lang / --js-bindings-only flags - // ------------------------------------------------------------------------- - [TestMethod] - public async Task InitCommand_WithJsBindingsOutput_OverridesDefaultOutputDir() + public async Task InitCommand_NativeCallerWithUseDefaults_OmitsJsBindingsBlock() { - // Arrange - Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); + // Standalone CLI (winget) keeps historical C++-only behavior even + // under --use-defaults — no prompt, no jsBindings. + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", null); var initCommand = GetRequiredService(); - var args = new[] - { - _tempDirectory.FullName, - "--config-only", - "--js-bindings", - "--js-bindings-output", "src/generated/winrt", - }; - - // Act + var args = new[] { _tempDirectory.FullName, "--config-only", "--use-defaults" }; var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); - // Assert Assert.AreEqual(0, exitCode); var configContent = await File.ReadAllTextAsync(Path.Combine(_tempDirectory.FullName, "winapp.yaml")); - StringAssert.Contains(configContent, "output: src/generated/winrt", - "--js-bindings-output must override the default 'bindings/winrt' in the persisted yaml"); - // Make sure we did not double-write a default output line as well. - Assert.DoesNotContain("output: bindings/winrt", configContent, - "Default output must be replaced, not appended"); + Assert.IsFalse(configContent.Contains("jsBindings:"), + "Native caller must not gain a jsBindings block — bindings prompt only fires for npm caller"); } [TestMethod] - public async Task InitCommand_JsBindingsSubOptionsWithoutFlag_FailsAsInvalidUsage() + public async Task InitCommand_NpmCallerOnDotNetProject_RejectedWithActionableError() { - // sub-options without --js-bindings are invalid - // usage (they'd silently no-op while init reports success — bad - // UX). Treat as exit 1 with a clear error message. - // Alias flags (--js-bindings-{preset}) bypass this — they imply the parent. + // .NET projects can't host JS bindings; the npm-caller prompt path + // tries to enable them via --use-defaults → Both, so the .NET guard + // must surface a clear error rather than silently producing junk yaml. Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); + var csprojPath = Path.Combine(_tempDirectory.FullName, "Sample.csproj"); + await File.WriteAllTextAsync(csprojPath, + "\n" + + " \n" + + " Exe\n" + + " net10.0-windows10.0.26100.0\n" + + " \n" + + "\n"); var initCommand = GetRequiredService(); - var args = new[] - { - _tempDirectory.FullName, - "--config-only", - "--js-bindings-output", "src/g", - }; - + var args = new[] { _tempDirectory.FullName, "--use-defaults" }; var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); Assert.AreEqual(1, exitCode, - "Sub-options without --js-bindings must fail loudly (exit 1) rather than silently no-op."); - var stderr = ConsoleStdErr.ToString(); - StringAssert.Contains(stderr, "require --js-bindings", - "Error must spell out the dependency on --js-bindings."); - StringAssert.Contains(stderr, "Error:", - "Should be surfaced as an Error, not a Warning."); - - var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); - if (File.Exists(configPath)) - { - var configContent = await File.ReadAllTextAsync(configPath); - Assert.DoesNotContain("jsBindings:", configContent, - "yaml must not gain a jsBindings block when init failed with invalid usage."); - } - } - - // --js-bindings-{preset} alias flags — each implies --js-bindings; - // multiple aliases union their package sets. - - [TestMethod] - public async Task InitCommand_WithJsBindingsAiAlias_ImpliesParentAndAppliesAiPackages() - { - // `--js-bindings-ai` alone (no `--js-bindings`) must apply the AI - // preset (expands to Microsoft.WindowsAppSDK.AI). - Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); - - var initCommand = GetRequiredService(); - var args = new[] - { - _tempDirectory.FullName, - "--config-only", - "--js-bindings-ai", - }; - - var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); - - Assert.AreEqual(0, exitCode); - var configContent = await File.ReadAllTextAsync(Path.Combine(_tempDirectory.FullName, "winapp.yaml")); - StringAssert.Contains(configContent, "jsBindings:", - "Alias must imply --js-bindings, so the jsBindings block is created"); - StringAssert.Contains(configContent, "packages:", - "AI preset (v2.0) writes a packages: list under jsBindings"); - foreach (var pkg in JsBindingsPresets.KnownPresets["ai"]) - { - StringAssert.Contains(configContent, pkg, - $"AI preset package id {pkg} must appear in the persisted yaml"); - } + "JS bindings on a .NET project must exit 1 (codegen target is Node/native, not .NET)."); + var combined = ConsoleStdErr.ToString() + ConsoleStdOut.ToString() + TestAnsiConsole.Output; + Assert.IsTrue( + combined.Contains(".NET", StringComparison.OrdinalIgnoreCase) + && combined.Contains("not supported", StringComparison.OrdinalIgnoreCase), + $"Error must call out the .NET-not-supported case. Combined output: {combined}"); } [TestMethod] - public async Task InitCommand_AliasFlagAlone_DoesNotTriggerSubOptionWarning() + public async Task InitCommand_NpmCallerWithSetupSdksNone_RejectsBecauseBindingsNeedSdks() { - // Regression guard: aliases imply --js-bindings, so the - // "sub-options without parent" warning must NOT fire when only an - // alias is given (without --js-bindings). + // npm-caller + --use-defaults requests Both, which needs SDK packages + // for the winmd source. --setup-sdks none conflicts → exit 1. Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); var initCommand = GetRequiredService(); - var args = new[] - { - _tempDirectory.FullName, - "--config-only", - "--js-bindings-ai", - }; - + var args = new[] { _tempDirectory.FullName, "--use-defaults", "--setup-sdks", "none" }; var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); - Assert.AreEqual(0, exitCode); - var stderr = ConsoleStdErr.ToString(); - Assert.IsFalse(stderr.Contains("require --js-bindings"), - "Alias implies --js-bindings; the invalid-usage error must not be printed."); - Assert.IsFalse(stderr.Contains("have no effect without --js-bindings"), - "Legacy warning text must not appear either (kept in case of partial revert)."); - } - - [TestMethod] - public async Task InitCommand_AllPresetAliasesRegistered() - { - // Meta-test: each KnownPreset must have a corresponding registered - // CLI flag. Catches regressions if someone adds a preset to the dict - // but breaks the auto-registration loop in InitCommand's static ctor. - foreach (var preset in JsBindingsPresets.KnownPresets.Keys) - { - Assert.IsTrue( - InitCommand.JsBindingsPresetAliasOptions.ContainsKey(preset), - $"Missing alias option for preset '{preset}'"); - var flag = JsBindingsPresets.AliasFlagName(preset); - var option = InitCommand.JsBindingsPresetAliasOptions[preset]; - CollectionAssert.Contains(option.Aliases.Concat(new[] { option.Name }).ToList(), flag, - $"Option for '{preset}' must surface as '{flag}' on the CLI"); - } + Assert.AreEqual(1, exitCode, + "--setup-sdks none + JS bindings (via npm caller default Both) must exit 1."); + var combined = ConsoleStdErr.ToString() + ConsoleStdOut.ToString() + TestAnsiConsole.Output; + Assert.IsTrue( + combined.Contains("none", StringComparison.OrdinalIgnoreCase) + && combined.Contains("SDK packages", StringComparison.OrdinalIgnoreCase), + $"Error must call out the setup-sdks=none conflict. Combined output: {combined}"); } - // Re-running init with --js-bindings on an existing yaml ADDS the - // jsBindings block without touching the existing packages: list. - [TestMethod] - public async Task InitCommand_OnExistingConfig_WithJsBindingsFlag_AddsBlockAndPreservesPackages() + public async Task InitCommand_NpmCallerWithExistingJsBindings_PreservesUserChoice() { + // Existing yaml that already declares jsBindings: must not be + // re-prompted; the existing choice (here: JS-only via + // cppProjections: false) round-trips through init. Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); var existing = """ + cppProjections: false packages: - name: Microsoft.WindowsAppSDK version: 1.8.39 - name: Microsoft.Windows.SDK.BuildTools version: 10.0.26100.5040 + jsBindings: + output: bindings/winrt + lang: js """; var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); await File.WriteAllTextAsync(configPath, existing); var initCommand = GetRequiredService(); - var args = new[] { _tempDirectory.FullName, "--config-only", "--js-bindings", "--use-defaults" }; - - // Act + var args = new[] { _tempDirectory.FullName, "--config-only", "--use-defaults" }; var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); - // Assert - Assert.AreEqual(0, exitCode, "Re-init with --js-bindings on existing yaml must succeed"); - + Assert.AreEqual(0, exitCode); var configContent = await File.ReadAllTextAsync(configPath); - // Packages preserved - StringAssert.Contains(configContent, "Microsoft.WindowsAppSDK", - "Re-init must NOT lose previously pinned packages"); - StringAssert.Contains(configContent, "1.8.39", - "Pinned package version must survive re-init"); - StringAssert.Contains(configContent, "Microsoft.Windows.SDK.BuildTools", - "Second pinned package must also survive re-init"); - // jsBindings block was added StringAssert.Contains(configContent, "jsBindings:", - "Re-init with --js-bindings must add the jsBindings block"); - StringAssert.Contains(configContent, "lang: js", - "Re-init must persist default lang=js"); - } - - // --js-bindings + --setup-sdks none is rejected at - // SetupWorkspaceAsync entry. Verify with the npm shim caller set - // (otherwise the npm-only gate fires first). - [TestMethod] - public async Task InitCommand_WithJsBindingsAndSetupSdksNone_RejectedWithActionableError() - { - Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); - - var initCommand = GetRequiredService(); - var args = new[] { _tempDirectory.FullName, "--js-bindings", "--setup-sdks", "none" }; - - var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); - - Assert.AreEqual(1, exitCode, - "--js-bindings + --setup-sdks none must exit 1 (no SDKs → nothing to scan for winmd)."); - var combined = ConsoleStdErr.ToString() + ConsoleStdOut.ToString() + TestAnsiConsole.Output; - Assert.IsTrue( - combined.Contains("--setup-sdks none", StringComparison.OrdinalIgnoreCase) - && combined.Contains("requires SDK packages", StringComparison.OrdinalIgnoreCase), - $"Error must call out the setup-sdks=none conflict. Combined output: {combined}"); - - // Yaml must not gain a jsBindings: block when the guard rejects. - var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); - if (File.Exists(configPath)) - { - var content = await File.ReadAllTextAsync(configPath); - Assert.IsFalse(content.Contains("jsBindings:"), - "Rejected init must NOT write a jsBindings: block."); - } - } - - // --js-bindings is unsupported on .NET (.csproj) projects. - [TestMethod] - public async Task InitCommand_WithJsBindingsOnDotNetProject_RejectedWithActionableError() - { - Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); - - // Seed a minimal .csproj so dotNetService.FindCsproj returns 1. - var csprojPath = Path.Combine(_tempDirectory.FullName, "Sample.csproj"); - await File.WriteAllTextAsync(csprojPath, - "\n" - + " \n" - + " Exe\n" - + " net10.0-windows10.0.26100.0\n" - + " \n" - + "\n"); - - var initCommand = GetRequiredService(); - var args = new[] { _tempDirectory.FullName, "--js-bindings", "--use-defaults" }; - - var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); - - Assert.AreEqual(1, exitCode, - "--js-bindings on a .NET project must exit 1 (codegen target is Node/native, not .NET)."); - var combined = ConsoleStdErr.ToString() + ConsoleStdOut.ToString() + TestAnsiConsole.Output; - Assert.IsTrue( - combined.Contains(".NET", StringComparison.OrdinalIgnoreCase) - && combined.Contains("not supported", StringComparison.OrdinalIgnoreCase), - $"Error must call out the .NET-not-supported case. Combined output: {combined}"); - - // Yaml must not be mutated. - var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); - if (File.Exists(configPath)) - { - var content = await File.ReadAllTextAsync(configPath); - Assert.IsFalse(content.Contains("jsBindings:"), - "Rejected init on .NET project must NOT write a jsBindings: block."); - } + "Existing jsBindings block must survive re-init."); + StringAssert.Contains(configContent, "Microsoft.WindowsAppSDK", + "Existing pinned packages must survive re-init."); } } -// init --js-bindings* path that injects the runtime dep via the -// extracted IJsBindingsWorkspaceService. Uses a fake service to verify the -// init→orchestration wiring without spawning real codegen. +// Verifies the init → orchestration wiring delivers the runtime-dep +// injection call once the npm-caller prompt opts into JS bindings. [TestClass] [DoNotParallelize] -public class InitCommandJsBindingsWiringTests : BaseCommandTests +public class InitCommandBindingsWiringTests : BaseCommandTests { private FakeJsBindingsWorkspaceService _fakeJsBindings = null!; private string? _savedCaller; @@ -580,55 +319,19 @@ public void TestTeardown() } [TestMethod] - public async Task InitCommand_WithJsBindings_InvokesEnsureRuntimeDependencyOnJsBindingsService() + public async Task InitCommand_NpmCallerWithUseDefaults_InvokesEnsureRuntimeDependency() { - // init --js-bindings (config-only) must route through the - // extracted IJsBindingsWorkspaceService for runtime-dep injection. + // npm caller + --use-defaults → Both → must wire @microsoft/dynwinrt. File.WriteAllText( Path.Combine(_tempDirectory.FullName, "package.json"), """{"name":"app","version":"1.0.0","dependencies":{}}"""); var initCommand = GetRequiredService(); - var args = new[] { _tempDirectory.FullName, "--config-only", "--js-bindings" }; + var args = new[] { _tempDirectory.FullName, "--config-only", "--use-defaults" }; var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); Assert.AreEqual(0, exitCode); Assert.IsTrue(_fakeJsBindings.EnsureRuntimeDependencyCalled, - "init --js-bindings must call IJsBindingsWorkspaceService.EnsureRuntimeDependencyAndPrintHint."); - } - - [TestMethod] - public async Task InitCommand_WithJsBindings_JsBindingsRunAsyncFailure_PropagatesNonZeroExit() - { - // Verifies the fake JsBindings service surfaces its non-zero exit - // when invoked directly. End-to-end SetupWorkspaceAsync propagation - // is covered by WorkspaceSetupServiceJsBindingsStepTests. - _fakeJsBindings.Result = new JsBindingsOrchestrationResult - { - ExitCode = 7, - Message = "simulated failure", - }; - - File.WriteAllText(Path.Combine(_tempDirectory.FullName, "winapp.yaml"), - "packages:\n - name: Microsoft.WindowsAppSDK\n version: 1.8.39\n"); - File.WriteAllText( - Path.Combine(_tempDirectory.FullName, "package.json"), - """{"name":"app","version":"1.0.0","dependencies":{}}"""); - - var addCmd = GetRequiredService(); - // The fake's AddAsync stub returns 0, but our specific point is to - // verify the RunAsync result wiring through orchestration. Set up - // the fake's Result and call RunAsync directly to confirm propagation. - var ctx = new JsBindingsOrchestrationContext - { - JsBindingsConfig = new Models.JsBindingsConfig { Output = "bindings/winrt" }, - WinappConfig = new Models.WinappConfig(), - WorkspaceDir = _tempDirectory, - LocalWinappDir = _tempDirectory.CreateSubdirectory(".winapp"), - NugetCacheDir = _tempDirectory, - }; - var result = await _fakeJsBindings.RunAsync(ctx, default!, CancellationToken.None); - Assert.AreEqual(7, result.ExitCode, - "Fake RunAsync must return the configured non-zero exit code (propagation contract)."); + "Default Both for npm caller must call IJsBindingsWorkspaceService.EnsureRuntimeDependencyAndPrintHint."); } } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/JsBindingsPresetsTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/JsBindingsPresetsTests.cs index d687e6b1..47c309f2 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/JsBindingsPresetsTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/JsBindingsPresetsTests.cs @@ -11,105 +11,6 @@ public class JsBindingsPresetsTests private static readonly string[] _arr00 = ["Microsoft.WindowsAppSDK.AI", "Some.Vendor.Pkg"]; private static readonly string[] _arr01 = ["Microsoft.WindowsAppSDK.AI", "Microsoft.WindowsAppSDK.WinUI"]; private static readonly string[] _arr02 = ["Microsoft.WindowsAppSDK.AI"]; - private static readonly string[] _arr03 = ["bogus", "ai", ""]; - private static readonly string[] _arr04 = ["ai", "AI", "ai"]; - private static readonly string[] _arr05 = ["ai"]; - - [TestMethod] - public void KnownPresets_AiPreset_MapsToAiPackage() - { - Assert.IsTrue(JsBindingsPresets.TryResolve("ai", out var packages)); - CollectionAssert.AreEqual( - _arr02, - packages.ToList(), - "AI preset must map to the Microsoft.WindowsAppSDK.AI NuGet package"); - } - - [TestMethod] - public void KnownPresets_OnlyShipsAi() - { - // Only 'ai' ships today; trip this test when a new one lands. - CollectionAssert.AreEqual( - _arr05, - JsBindingsPresets.KnownPresets.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase).ToList(), - "Unexpected preset registered. If intentional, update this test and docs/js-bindings.md."); - } - - [TestMethod] - public void TryResolve_IsCaseInsensitive() - { - Assert.IsTrue(JsBindingsPresets.TryResolve("AI", out var ai)); - Assert.AreEqual(1, ai.Count); - Assert.IsTrue(JsBindingsPresets.TryResolve("Ai", out var aiMixed)); - Assert.AreEqual(1, aiMixed.Count); - } - - [TestMethod] - public void TryResolve_UnknownPreset_ReturnsFalseAndEmpty() - { - Assert.IsFalse(JsBindingsPresets.TryResolve("unknown-xyz", out var packages)); - Assert.AreEqual(0, packages.Count); - } - - [TestMethod] - public void KnownPresetsDisplay_IsAlphabeticallyOrdered() - { - var display = JsBindingsPresets.KnownPresetsDisplay(); - Assert.AreEqual("ai", display, - "Display must be alphabetical so help output is stable"); - } - - // ResolveAndUnion — multi-preset union (only 'ai' ships today). - - [TestMethod] - public void ResolveAndUnion_EmptyInput_ReturnsEmpty() - { - var result = JsBindingsPresets.ResolveAndUnion(Array.Empty()); - Assert.AreEqual(0, result.Count); - } - - [TestMethod] - public void ResolveAndUnion_SinglePreset_EquivalentToTryResolve() - { - var result = JsBindingsPresets.ResolveAndUnion(_arr05); - CollectionAssert.AreEqual( - _arr02, - result.ToList(), - "Single-preset union must produce the preset's package IDs in declaration order"); - } - - [TestMethod] - public void ResolveAndUnion_DuplicatePresets_DedupesPackageIds() - { - // ai listed multiple times (mixed case to also exercise the - // case-insensitive comparer) — must collapse to one set. - var result = JsBindingsPresets.ResolveAndUnion(_arr04); - CollectionAssert.AreEqual( - _arr02, - result.ToList(), - "Repeated presets must collapse — no double package IDs"); - } - - [TestMethod] - public void ResolveAndUnion_UnknownNames_AreSkippedSilently() - { - // Skip unknown but keep the known one. Validation is the CLI parser's job. - var result = JsBindingsPresets.ResolveAndUnion(_arr03); - CollectionAssert.AreEqual( - _arr02, - result.ToList()); - } - - [TestMethod] - public void AliasFlagName_FormatsAsExpected() - { - // Single source of truth for the --js-bindings-{preset} naming scheme. - Assert.AreEqual("--js-bindings-ai", JsBindingsPresets.AliasFlagName("ai")); - Assert.AreEqual("--js-bindings-ai", JsBindingsPresets.AliasFlagName("AI"), - "Casing of input must not leak into the flag name"); - Assert.AreEqual("--js-bindings-future", JsBindingsPresets.AliasFlagName("Future"), - "Format must work for any preset name (regression guard for future presets)"); - } // Per-package winmd categorization — emit / ref-only / skip. diff --git a/src/winapp-CLI/WinApp.Cli.Tests/TestDoubles/FakeJsBindingsWorkspaceService.cs b/src/winapp-CLI/WinApp.Cli.Tests/TestDoubles/FakeJsBindingsWorkspaceService.cs index fded92c9..f30de886 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/TestDoubles/FakeJsBindingsWorkspaceService.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/TestDoubles/FakeJsBindingsWorkspaceService.cs @@ -35,20 +35,4 @@ public void EnsureRuntimeDependencyAndPrintHint(DirectoryInfo workspaceDirectory { EnsureRuntimeDependencyCalled = true; } - - // AddAsync isn't exercised by the init/restore wiring tests (those go - // through RunAsync). Stub returns success so the interface is satisfied. - public Task AddAsync(AddJsBindingsOptions options, CancellationToken cancellationToken = default) - => Task.FromResult(0); - - public List GenerateCalls { get; } = new(); - - // When set, GenerateAsync returns this exit code instead of 0. - public int GenerateResult { get; set; } - - public Task GenerateAsync(GenerateJsBindingsOptions options, CancellationToken cancellationToken = default) - { - GenerateCalls.Add(options); - return Task.FromResult(GenerateResult); - } } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/WinappConfigDocumentTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/WinappConfigDocumentTests.cs index f1c91405..d858ee31 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/WinappConfigDocumentTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/WinappConfigDocumentTests.cs @@ -393,7 +393,7 @@ public void SpliceJsBindingsInto_PreservesLeadingCommentAndTrailingSections() // including a leading comment line and the trailing packages: // section. ConfigService.SaveJsBindingsOnly is the production // caller; if this drifts, user-authored YAML loses comments - // every time `node jsbindings add/generate` runs. + // every time the JS bindings step runs. var existing = string.Join('\n', new[] { "# user-managed file — do not edit jsBindings by hand", diff --git a/src/winapp-CLI/WinApp.Cli.Tests/WorkspaceSetupServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/WorkspaceSetupServiceTests.cs index d90eb487..c97e9d6c 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/WorkspaceSetupServiceTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/WorkspaceSetupServiceTests.cs @@ -192,121 +192,6 @@ public async Task SetupWorkspace_WithRequireExistingConfig_NoOpsWhenConfigMissin } } -// M2 (round-6): restore on a jsbindings-only workspace (packages: empty, -// jsBindings: declared) used to short-circuit at the empty-packages early- -// return, never regenerating bindings. Now it forwards to GenerateAsync. -[TestClass] -[DoNotParallelize] -public class WorkspaceSetupServiceJsBindingsOnlyRestoreTests : BaseCommandTests -{ - private FakeJsBindingsWorkspaceService _fakeJsBindings = null!; - - protected override IServiceCollection ConfigureServices(IServiceCollection services) - { - _fakeJsBindings = new FakeJsBindingsWorkspaceService(); - var existing = services.FirstOrDefault(d => d.ServiceType == typeof(IJsBindingsWorkspaceService)); - if (existing is not null) - { - services.Remove(existing); - } - services.AddSingleton(_fakeJsBindings); - return services; - } - - [TestMethod] - public async Task Restore_JsBindingsOnly_NoPackages_InvokesGenerateAsync() - { - // Yaml with only a jsBindings: block — no packages: at all. - // Pre-r6 the empty-packages early-return swallowed this case. - var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); - await File.WriteAllTextAsync(configPath, - "jsBindings:\n" - + " output: bindings/winrt\n" - + " lang: js\n"); - - var workspaceSetupService = GetRequiredService(); - var options = new WorkspaceSetupOptions - { - BaseDirectory = _tempDirectory, - ConfigDir = _tempDirectory, - RequireExistingConfig = true, - UseDefaults = true, - NoGitignore = true, - }; - - var exitCode = await workspaceSetupService.SetupWorkspaceAsync(options, TestContext.CancellationToken); - - Assert.AreEqual(0, exitCode, "Restore must succeed for jsbindings-only yaml."); - Assert.AreEqual(1, _fakeJsBindings.GenerateCalls.Count, - "Restore must call GenerateAsync exactly once when jsBindings: is present and packages: is empty."); - Assert.IsTrue(_fakeJsBindings.EnsureRuntimeDependencyCalled, - "Restore on jsbindings-only must also ensure @microsoft/dynwinrt is in package.json (parity with init --js-bindings)."); - } - - [TestMethod] - public async Task Restore_JsBindingsOnly_GenerateFailure_PropagatesNonZero() - { - var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); - await File.WriteAllTextAsync(configPath, - "jsBindings:\n" - + " output: bindings/winrt\n" - + " lang: js\n"); - - _fakeJsBindings.GenerateResult = 7; - - var workspaceSetupService = GetRequiredService(); - var options = new WorkspaceSetupOptions - { - BaseDirectory = _tempDirectory, - ConfigDir = _tempDirectory, - RequireExistingConfig = true, - UseDefaults = true, - NoGitignore = true, - }; - - var exitCode = await workspaceSetupService.SetupWorkspaceAsync(options, TestContext.CancellationToken); - - Assert.AreEqual(7, exitCode, "Restore must propagate GenerateAsync's non-zero exit code."); - Assert.IsFalse(_fakeJsBindings.EnsureRuntimeDependencyCalled, - "Runtime-dep injection must be skipped when generate fails (don't mutate package.json on a failed regen)."); - } - - [TestMethod] - public async Task Restore_EmptyYaml_NoPackagesNoJsBindings_DoesNotInvokeGenerateAsync() - { - // Pure no-op path: empty yaml, no jsBindings: — must NOT route through - // the jsbindings-only restore branch. The downstream SDK install flow - // is environment-dependent (cppwinrt is not available in the unit-test - // environment) and is covered by integration tests elsewhere, so this - // test only asserts the part that is mine to guard: GenerateAsync is - // not falsely triggered by the M2 short-circuit when JsBindings is - // null. - var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); - await File.WriteAllTextAsync(configPath, "# nothing here\n"); - - var workspaceSetupService = GetRequiredService(); - var options = new WorkspaceSetupOptions - { - BaseDirectory = _tempDirectory, - ConfigDir = _tempDirectory, - RequireExistingConfig = true, - UseDefaults = true, - NoGitignore = true, - }; - - try - { - await workspaceSetupService.SetupWorkspaceAsync(options, TestContext.CancellationToken); - } - catch - { - // Downstream cppwinrt / SDK install failures are out of scope here. - } - - Assert.AreEqual(0, _fakeJsBindings.GenerateCalls.Count, - "Empty yaml without jsBindings: must NOT trigger codegen."); - } -} /// /// End-to-end tests for the merged .NET / native workspace setup. Verifies the diff --git a/src/winapp-CLI/WinApp.Cli/Commands/AddJsBindingsCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/AddJsBindingsCommand.cs deleted file mode 100644 index 38a632a1..00000000 --- a/src/winapp-CLI/WinApp.Cli/Commands/AddJsBindingsCommand.cs +++ /dev/null @@ -1,158 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using System.CommandLine; -using System.CommandLine.Invocation; -using WinApp.Cli.Services; - -namespace WinApp.Cli.Commands; - -// `node jsbindings add` — layered, non-destructive. Requires existing -// winapp.yaml; never touches packages: or installs SDK packages. -internal class AddJsBindingsCommand : Command, IShortDescription -{ - public string ShortDescription => "Add JS/TS bindings to an existing workspace"; - - // Caller value emitted by the npm winapp shim; required to use this command. - private const string NpmShimCaller = "nodejs-package"; - - public static Argument BaseDirectoryArgument { get; } - public static Option ConfigDirOption { get; } - public static Option OutputOption { get; } - public static Option ForceOption { get; } - public static Option UseDefaultsOption { get; } - - // Per-preset --{preset} alias flags. Auto-populated from - // JsBindingsPresets.KnownPresets. - public static IReadOnlyDictionary> PresetAliasOptions { get; } - - static AddJsBindingsCommand() - { - BaseDirectoryArgument = new Argument("base-directory") - { - Description = "Base/root directory for the winapp workspace (default: current directory)", - Arity = ArgumentArity.ZeroOrOne, - }; - BaseDirectoryArgument.AcceptExistingOnly(); - - ConfigDirOption = new Option("--config-dir") - { - Description = "Directory containing winapp.yaml (default: base-directory)", - }; - ConfigDirOption.AcceptExistingOnly(); - - OutputOption = new Option("--output") - { - Description = "Output directory for generated JS/TS bindings (relative to workspace, default 'bindings/winrt'). " - + "Persisted to winapp.yaml's jsBindings.output field.", - HelpName = "PATH", - }; - - ForceOption = new Option("--force") - { - Description = "Patch an existing jsBindings: block without prompting. " - + "Overwrites only output and (when a preset like --ai is supplied) the packages list; " - + "all other fields are preserved. Without --force the command refuses to clobber a " - + "pre-existing block (interactive: prompts; non-interactive: errors).", - }; - - UseDefaultsOption = new Option("--use-defaults", "--no-prompt") - { - Description = "Do not prompt. When jsBindings: already exists in winapp.yaml, " - + "preserve it and exit 0 (idempotent). Use --force instead if you want " - + "the existing block patched non-interactively.", - }; - - var aliases = new Dictionary>(StringComparer.OrdinalIgnoreCase); - foreach (var preset in JsBindingsPresets.KnownPresets.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase)) - { - var flag = JsBindingsPresets.AddAliasFlagName(preset); - aliases[preset] = new Option(flag) - { - Description = $"Generate bindings for the '{preset}' slice of the SDK only. " - + "For a custom slice that no preset covers, edit winapp.yaml's jsBindings.packages " - + $"after adding. Known presets: {JsBindingsPresets.KnownPresetsDisplay()}.", - }; - } - PresetAliasOptions = aliases; - } - - public AddJsBindingsCommand() : base( - "add", - "Add a jsBindings: block to winapp.yaml and run codegen. " - + "Requires winapp.yaml (run 'winapp init' first). Never modifies the packages: section " - + "or installs SDK packages — codegen runs against the workspace's already-restored packages. " - + "Refuses to clobber an existing jsBindings: block unless --force is passed. " - + "Only available when invoked via the @microsoft/winappcli npm package " - + "(npx winapp node jsbindings add).") - { - Arguments.Add(BaseDirectoryArgument); - Options.Add(ConfigDirOption); - Options.Add(OutputOption); - Options.Add(ForceOption); - Options.Add(UseDefaultsOption); - foreach (var aliasOption in PresetAliasOptions.Values) - { - Options.Add(aliasOption); - } - } - - public class Handler(IJsBindingsWorkspaceService jsBindingsWorkspaceService, ICurrentDirectoryProvider currentDirectoryProvider) : AsynchronousCommandLineAction - { - public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) - { - var baseDirectory = parseResult.GetValue(BaseDirectoryArgument) ?? currentDirectoryProvider.GetCurrentDirectoryInfo(); - var configDir = parseResult.GetValue(ConfigDirOption) ?? baseDirectory; - var output = parseResult.GetValue(OutputOption); - var force = parseResult.GetValue(ForceOption); - var useDefaults = parseResult.GetValue(UseDefaultsOption); - - if (force && useDefaults) - { - var stderr = parseResult.InvocationConfiguration.Error; - await stderr.WriteLineAsync( - "Error: --force and --use-defaults are mutually exclusive. " - + "--force patches an existing jsBindings: block; --use-defaults preserves it. Pick one."); - return 1; - } - - // Iteration order is alphabetical (static ctor), so the prefix - // union below is deterministic regardless of cmdline arg order. - var enabledPresets = PresetAliasOptions - .Where(kv => parseResult.GetValue(kv.Value)) - .Select(kv => kv.Key) - .ToList(); - - // Gate behind the npm shim. Same rationale as InitCommand: the - // codegen tool is an npm transitive dep of @microsoft/winappcli; - // running this from any other entry point will fail at the - // codegen invocation with a less actionable error. - var caller = Environment.GetEnvironmentVariable("WINAPP_CLI_CALLER"); - if (!string.Equals(caller, NpmShimCaller, StringComparison.Ordinal)) - { - var stderr = parseResult.InvocationConfiguration.Error; - await stderr.WriteLineAsync( - "Error: 'node jsbindings add' requires the @microsoft/winappcli npm package."); - await stderr.WriteLineAsync( - "JS/TS bindings depend on @microsoft/dynwinrt-codegen, which is installed"); - await stderr.WriteLineAsync( - "as a transitive npm dependency. To use this command:"); - await stderr.WriteLineAsync(" npm i -D @microsoft/winappcli"); - await stderr.WriteLineAsync(" npx winapp node jsbindings add"); - return 1; - } - - var options = new AddJsBindingsOptions - { - BaseDirectory = baseDirectory, - ConfigDir = configDir, - Output = output, - Presets = enabledPresets.Count > 0 ? enabledPresets : null, - Force = force, - UseDefaults = useDefaults, - }; - - return await jsBindingsWorkspaceService.AddAsync(options, cancellationToken); - } - } -} diff --git a/src/winapp-CLI/WinApp.Cli/Commands/GenerateJsBindingsCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/GenerateJsBindingsCommand.cs deleted file mode 100644 index d7b38987..00000000 --- a/src/winapp-CLI/WinApp.Cli/Commands/GenerateJsBindingsCommand.cs +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using System.CommandLine; -using System.CommandLine.Invocation; -using WinApp.Cli.Helpers; -using WinApp.Cli.Services; - -namespace WinApp.Cli.Commands; - -// `node jsbindings generate` — read-only codegen. Loads the existing -// jsBindings: block from winapp.yaml and runs dynwinrt-codegen against -// the workspace's already-restored packages. Does NOT mutate yaml. -internal class GenerateJsBindingsCommand : Command, IShortDescription -{ - private const string NpmShimCaller = "nodejs-package"; - - public string ShortDescription => "Re-run codegen against the existing jsBindings: block"; - - public static Argument BaseDirectoryArgument { get; } - public static Option ConfigDirOption { get; } - - static GenerateJsBindingsCommand() - { - BaseDirectoryArgument = new Argument("base-directory") - { - Description = "Base/root directory for the winapp workspace (default: current directory)", - Arity = ArgumentArity.ZeroOrOne, - }; - BaseDirectoryArgument.AcceptExistingOnly(); - - ConfigDirOption = new Option("--config-dir") - { - Description = "Directory containing winapp.yaml (default: base-directory)", - }; - ConfigDirOption.AcceptExistingOnly(); - } - - public GenerateJsBindingsCommand() : base( - "generate", - "Re-run dynwinrt-codegen against the existing jsBindings: block in winapp.yaml. " - + "Does NOT modify the yaml — for that, use 'node jsbindings add'. " - + "Errors if no jsBindings: block is declared. " - + "Only available when invoked via the @microsoft/winappcli npm package " - + "(npx winapp node jsbindings generate).") - { - Arguments.Add(BaseDirectoryArgument); - Options.Add(ConfigDirOption); - } - - public class Handler(IJsBindingsWorkspaceService jsBindingsWorkspaceService, ICurrentDirectoryProvider currentDirectoryProvider) : AsynchronousCommandLineAction - { - public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) - { - var baseDirectory = parseResult.GetValue(BaseDirectoryArgument) ?? currentDirectoryProvider.GetCurrentDirectoryInfo(); - var configDir = parseResult.GetValue(ConfigDirOption) ?? baseDirectory; - - var caller = Environment.GetEnvironmentVariable("WINAPP_CLI_CALLER"); - if (!string.Equals(caller, NpmShimCaller, StringComparison.Ordinal)) - { - var stderr = parseResult.InvocationConfiguration.Error; - await stderr.WriteLineAsync( - "Error: 'node jsbindings generate' requires the @microsoft/winappcli npm package."); - await stderr.WriteLineAsync( - "JS/TS bindings depend on @microsoft/dynwinrt-codegen, which is installed"); - await stderr.WriteLineAsync( - "as a transitive npm dependency. To use this command:"); - await stderr.WriteLineAsync(" npm i -D @microsoft/winappcli"); - await stderr.WriteLineAsync(" npx winapp node jsbindings generate"); - return 1; - } - - var options = new GenerateJsBindingsOptions - { - BaseDirectory = baseDirectory, - ConfigDir = configDir, - }; - - return await jsBindingsWorkspaceService.GenerateAsync(options, cancellationToken); - } - } -} diff --git a/src/winapp-CLI/WinApp.Cli/Commands/InitCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/InitCommand.cs index 4b783bcc..75d7d711 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/InitCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/InitCommand.cs @@ -18,18 +18,6 @@ internal class InitCommand : Command, IShortDescription public static Option NoGitignoreOption { get; } public static Option UseDefaults { get; } public static Option ConfigOnlyOption { get; } - public static Option JsBindingsOption { get; } - public static Option JsBindingsOutputOption { get; } - public static Option JsBindingsLangOption { get; } - - // Per-preset alias flags (e.g. --js-bindings-ai). Auto-populated from - // JsBindingsPresets.KnownPresets; each implies --js-bindings, and - // multiple combine via union. - public static IReadOnlyDictionary> JsBindingsPresetAliasOptions { get; } - - // WINAPP_CLI_CALLER emitted by the npm shim. --js-bindings requires this - // caller because codegen ships as an npm transitive dep. - private const string NpmShimCaller = "nodejs-package"; static InitCommand() { @@ -65,44 +53,9 @@ static InitCommand() { Description = "Only handle configuration file operations (create if missing, validate if exists). Skip package installation and other workspace setup steps." }; - JsBindingsOption = new Option("--js-bindings") - { - Description = "Generate JS/TS bindings via dynwinrt-codegen on top of the standard init flow. " - + "Adds a 'jsBindings:' block to winapp.yaml so the binding generator runs as part of init/restore. " - + "Only available when invoked via the @microsoft/winappcli npm package (npx winapp init --js-bindings)." - }; - JsBindingsOutputOption = new Option("--js-bindings-output") - { - Description = "Override the output directory for generated JS/TS bindings (relative to workspace, default 'bindings/winrt'). " - + "Only takes effect together with --js-bindings on a fresh init; ignored on re-init when winapp.yaml already declares jsBindings:.", - HelpName = "PATH", - }; - JsBindingsLangOption = new Option("--js-bindings-lang") - { - Description = "Override the JS bindings language. Currently only 'js' is supported (which emits both .js and .d.ts). " - + "Reserved for forward-compat; see --js-bindings-output for activation rules.", - HelpName = "js", - }; - JsBindingsLangOption.AcceptOnlyFromAmong("js"); - - // One --js-bindings-{preset} flag per preset, alphabetised. - var aliases = new Dictionary>(StringComparer.OrdinalIgnoreCase); - foreach (var preset in JsBindingsPresets.KnownPresets.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase)) - { - var flag = JsBindingsPresets.AliasFlagName(preset); - aliases[preset] = new Option(flag) - { - Description = $"Generate bindings for the '{preset}' slice of the SDK. " - + "Implies --js-bindings (no need to pass it separately). " - + "For a custom slice that no preset covers, edit winapp.yaml " - + "and write your own packages: list under jsBindings. " - + $"Known presets: {JsBindingsPresets.KnownPresetsDisplay()}.", - }; - } - JsBindingsPresetAliasOptions = aliases; } - public InitCommand() : base("init", "Start here for initializing a Windows app with required setup. Sets up everything needed for Windows app development: creates Package.appxmanifest with default assets, downloads Windows SDK and Windows App SDK packages, and generates projections. When SDK packages are managed (--setup-sdks stable/preview/experimental), also creates winapp.yaml to pin versions for 'restore'/'update'; with --setup-sdks none (e.g., for Rust/Tauri projects that bring their own SDK bindings), no winapp.yaml is created. Interactive by default (use --use-defaults to skip prompts). Use 'restore' instead if you cloned a repo that already has winapp.yaml. Use 'manifest generate' if you only need a manifest, or 'cert generate' if you need a development certificate for code signing.") + public InitCommand() : base("init", "Start here for initializing a Windows app with required setup. Sets up everything needed for Windows app development: creates Package.appxmanifest with default assets, downloads Windows SDK and Windows App SDK packages, and generates projections. When SDK packages are managed (--setup-sdks stable/preview/experimental), also creates winapp.yaml to pin versions for 'restore'/'update'; with --setup-sdks none (e.g., for Rust/Tauri projects that bring their own SDK bindings), no winapp.yaml is created. When invoked via the @microsoft/winappcli npm package (npx winapp init), additionally asks whether to generate C++ projections, JS/TS bindings, or both. Interactive by default (use --use-defaults to skip prompts). Use 'restore' instead if you cloned a repo that already has winapp.yaml. Use 'manifest generate' if you only need a manifest, or 'cert generate' if you need a development certificate for code signing.") { Arguments.Add(BaseDirectoryArgument); Options.Add(ConfigDirOption); @@ -111,13 +64,6 @@ static InitCommand() Options.Add(NoGitignoreOption); Options.Add(UseDefaults); Options.Add(ConfigOnlyOption); - Options.Add(JsBindingsOption); - Options.Add(JsBindingsOutputOption); - Options.Add(JsBindingsLangOption); - foreach (var aliasOption in JsBindingsPresetAliasOptions.Values) - { - Options.Add(aliasOption); - } } public class Handler(IWorkspaceSetupService workspaceSetupService, ICurrentDirectoryProvider currentDirectoryProvider) : AsynchronousCommandLineAction @@ -131,55 +77,6 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio var noGitignore = parseResult.GetValue(NoGitignoreOption); var useDefaults = parseResult.GetValue(UseDefaults); var configOnly = parseResult.GetValue(ConfigOnlyOption); - var jsBindings = parseResult.GetValue(JsBindingsOption); - var jsBindingsOutput = parseResult.GetValue(JsBindingsOutputOption); - var jsBindingsLang = parseResult.GetValue(JsBindingsLangOption); - - // Iteration order is alphabetical(registration order), so the - // resulting prefix union is deterministic. - var enabledAliases = JsBindingsPresetAliasOptions - .Where(kv => parseResult.GetValue(kv.Value)) - .Select(kv => kv.Key) - .ToList(); - - // Aliases imply --js-bindings (the whole point of the shorthand). - // Promote BEFORE the warning check below. - if (enabledAliases.Count > 0 && !jsBindings) - { - jsBindings = true; - } - - if (!jsBindings && - (!string.IsNullOrWhiteSpace(jsBindingsOutput) - || !string.IsNullOrWhiteSpace(jsBindingsLang))) - { - var stderr = parseResult.InvocationConfiguration.Error; - await stderr.WriteLineAsync( - "Error: --js-bindings-output / --js-bindings-lang require --js-bindings. " - + "Add --js-bindings (or one of the alias flags like --js-bindings-ai) to enable bindings generation."); - return 1; - } - - // Gate --js-bindings behind the npm shim — codegen ships as an - // npm transitive dep of @microsoft/winappcli, so non-npm callers - // would silently produce a broken workspace. - if (jsBindings) - { - var caller = Environment.GetEnvironmentVariable("WINAPP_CLI_CALLER"); - if (!string.Equals(caller, NpmShimCaller, StringComparison.Ordinal)) - { - var stderr = parseResult.InvocationConfiguration.Error; - await stderr.WriteLineAsync( - "Error: --js-bindings requires the @microsoft/winappcli npm package."); - await stderr.WriteLineAsync( - "JS/TS bindings depend on @microsoft/dynwinrt-codegen, which is installed"); - await stderr.WriteLineAsync( - "as a transitive npm dependency. To use this flag:"); - await stderr.WriteLineAsync(" npm i -D @microsoft/winappcli"); - await stderr.WriteLineAsync(" npx winapp init --js-bindings"); - return 1; - } - } var options = new WorkspaceSetupOptions { @@ -192,10 +89,6 @@ await stderr.WriteLineAsync( RequireExistingConfig = false, ForceLatestBuildTools = true, ConfigOnly = configOnly, - AddJsBindings = jsBindings, - JsBindingsOutputOverride = jsBindings ? jsBindingsOutput : null, - JsBindingsLangOverride = jsBindings ? jsBindingsLang : null, - JsBindingsPresets = jsBindings && enabledAliases.Count > 0 ? enabledAliases : null, }; return await workspaceSetupService.SetupWorkspaceAsync(options, cancellationToken); diff --git a/src/winapp-CLI/WinApp.Cli/Commands/JsBindingsCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/JsBindingsCommand.cs deleted file mode 100644 index c4b48035..00000000 --- a/src/winapp-CLI/WinApp.Cli/Commands/JsBindingsCommand.cs +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using System.CommandLine; - -namespace WinApp.Cli.Commands; - -// `node jsbindings` verb. Hosts subcommands that operate on the -// jsBindings: block of winapp.yaml — `add` (mutates yaml + codegen) and -// `generate` (read-only codegen). -internal class JsBindingsCommand : Command, IShortDescription -{ - public string ShortDescription => "Manage JS/TS WinRT bindings (npm-only)"; - - public JsBindingsCommand( - AddJsBindingsCommand addJsBindingsCommand, - GenerateJsBindingsCommand generateJsBindingsCommand) - : base("jsbindings", "Manage JS/TS WinRT bindings for an existing workspace. " - + "'add' mutates winapp.yaml + runs codegen; 'generate' just runs codegen " - + "against the existing yaml. Only available via the @microsoft/winappcli npm package.") - { - // Kebab-case alias — matches the --js-bindings flag name in init. - Aliases.Add("js-bindings"); - Subcommands.Add(addJsBindingsCommand); - Subcommands.Add(generateJsBindingsCommand); - } -} diff --git a/src/winapp-CLI/WinApp.Cli/Commands/NodeCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/NodeCommand.cs deleted file mode 100644 index 2a3c3471..00000000 --- a/src/winapp-CLI/WinApp.Cli/Commands/NodeCommand.cs +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using System.CommandLine; - -namespace WinApp.Cli.Commands; - -// `node` verb in the .NET CLI tree. Hosts Node.js / Electron-specific -// sub-commands that need real CLI work (currently `jsbindings`). -// Wrapper-only commands (`create-addon`, `add-electron-debug-identity`, -// `clear-electron-debug-identity`) are implemented in the npm shim and -// never reach this command — the shim intercepts them locally. -internal class NodeCommand : Command, IShortDescription -{ - public string ShortDescription => "Node.js / Electron-specific commands (npm-only)"; - - public NodeCommand(JsBindingsCommand jsBindingsCommand) - : base("node", "Node.js / Electron-specific winapp commands. Only available when invoked via " - + "the @microsoft/winappcli npm package (npx winapp node ...).") - { - Subcommands.Add(jsBindingsCommand); - } -} diff --git a/src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs index c4767a74..766e777e 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs @@ -71,8 +71,7 @@ public WinAppRootCommand( IAnsiConsole ansiConsole, CreateExternalCatalogCommand createExternalCatalogCommand, CompleteCommand completeCommand, - UiCommand uiCommand, - NodeCommand nodeCommand) : base("CLI for Windows app development, including package identity, packaging, managing Package.appxmanifest, test certificates, Windows (App) SDK projections, and more. For use with any app framework targeting Windows") + UiCommand uiCommand) : base("CLI for Windows app development, including package identity, packaging, managing Package.appxmanifest, test certificates, Windows (App) SDK projections, and more. For use with any app framework targeting Windows") { Subcommands.Add(initCommand); Subcommands.Add(restoreCommand); @@ -90,7 +89,6 @@ public WinAppRootCommand( Subcommands.Add(createExternalCatalogCommand); Subcommands.Add(uiCommand); Subcommands.Add(completeCommand); - Subcommands.Add(nodeCommand); Options.Add(CliSchemaOption); Options.Add(CallerOption); @@ -104,7 +102,6 @@ public WinAppRootCommand( ("Setup", [typeof(InitCommand), typeof(RestoreCommand), typeof(UpdateCommand)]), ("Packaging & Signing", [typeof(PackageCommand), typeof(SignCommand), typeof(CertCommand), typeof(ManifestCommand), typeof(CreateExternalCatalogCommand)]), ("Development Tools", [typeof(CreateDebugIdentityCommand), typeof(MSStoreCommand), typeof(ToolCommand), typeof(GetWinappPathCommand), typeof(RunCommand), typeof(UnregisterCommand)]), - ("Node.js / Electron", [typeof(NodeCommand)]), ("UI Automation", [typeof(UiCommand)]) ); } diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs b/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs index 77bcea74..d75aa22b 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs @@ -82,11 +82,6 @@ public static IServiceCollection ConfigureCommands(this IServiceCollection servi .UseCommandHandler() .UseCommandHandler(false) .UseCommandHandler() - // `node` verb tree (Node.js / Electron-specific, npm-only) - .ConfigureCommand() - .ConfigureCommand() - .UseCommandHandler() - .UseCommandHandler() // UI Automation commands .ConfigureCommand() .UseCommandHandler() diff --git a/src/winapp-CLI/WinApp.Cli/Models/WinappConfig.cs b/src/winapp-CLI/WinApp.Cli/Models/WinappConfig.cs index fe5e3ec2..359b182a 100644 --- a/src/winapp-CLI/WinApp.Cli/Models/WinappConfig.cs +++ b/src/winapp-CLI/WinApp.Cli/Models/WinappConfig.cs @@ -10,6 +10,12 @@ internal sealed class WinappConfig // Optional JS/TS bindings; when set, restore runs the codegen step. public JsBindingsConfig? JsBindings { get; set; } + // Whether to generate C++/WinRT projections (cppwinrt headers + headers/libs/runtimes + // copy). Default true preserves the pre-existing behavior; only `winapp init` writes + // `false` when the npm caller picks "JS only" so pure-Node projects skip ~130MB of + // cppwinrt output. Yaml key: `cppProjections`. + public bool CppProjections { get; set; } = true; + public string? GetVersion(string name) => Packages.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase))?.Version; diff --git a/src/winapp-CLI/WinApp.Cli/Models/WinmdsLockfile.cs b/src/winapp-CLI/WinApp.Cli/Models/WinmdsLockfile.cs index c1389210..fb4d42ec 100644 --- a/src/winapp-CLI/WinApp.Cli/Models/WinmdsLockfile.cs +++ b/src/winapp-CLI/WinApp.Cli/Models/WinmdsLockfile.cs @@ -5,7 +5,8 @@ namespace WinApp.Cli.Models; -// Lockfile written by `winapp restore`, consumed by `node jsbindings add`. Optional. +// Lockfile written by `winapp restore`, consumed by the JS bindings step on +// subsequent `winapp restore` runs to keep codegen stable. Optional. internal sealed class WinmdsLockfile { // Current schema version. Bump on breaking shape changes. @@ -20,8 +21,8 @@ internal sealed class WinmdsLockfile // NuGet global packages dir at write time. Diagnostic-only. public string? NugetCacheDir { get; set; } - // SHA-256 of the yaml packages: block. node jsbindings add - // treats the lockfile as stale on mismatch. + // SHA-256 of the yaml packages: block. The JS bindings step treats the + // lockfile as stale on mismatch and re-discovers winmds from the cache. public string? YamlPackagesHash { get; set; } // One entry per resolved package (direct + transitive). diff --git a/src/winapp-CLI/WinApp.Cli/Services/IJsBindingsWorkspaceService.cs b/src/winapp-CLI/WinApp.Cli/Services/IJsBindingsWorkspaceService.cs index 1a91fb78..6a2351f3 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/IJsBindingsWorkspaceService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/IJsBindingsWorkspaceService.cs @@ -6,7 +6,8 @@ namespace WinApp.Cli.Services; -// Single owner of the JS-bindings pipeline; used by both init/restore and add. +// Single owner of the JS-bindings pipeline; invoked from init/restore Step 5.5 +// when winapp.yaml declares a jsBindings: block. internal interface IJsBindingsWorkspaceService { // discover → partition → resolve user winmds → codegen → ensure runtime dep. @@ -17,18 +18,9 @@ Task RunAsync( // Inject @microsoft/dynwinrt into package.json as a production dep, then // print a package-manager-aware install hint. Called early in init when - // --js-bindings is set so users can `npm install` while codegen runs. + // the npm-caller prompt opted into JS bindings so users can `npm install` + // while codegen runs. void EnsureRuntimeDependencyAndPrintHint(DirectoryInfo workspaceDirectory); - - // Top-level `node jsbindings add` flow: load winapp.yaml, prompt about - // existing block, splice-save, invoke RunAsync, cleanup old output dir. - // Returns the exit code suitable for the System.CommandLine handler. - Task AddAsync(AddJsBindingsOptions options, CancellationToken cancellationToken = default); - - // Top-level `node jsbindings generate` flow: read existing jsBindings: - // block from winapp.yaml without mutation, then run codegen. Errors if - // no jsBindings: block exists. - Task GenerateAsync(GenerateJsBindingsOptions options, CancellationToken cancellationToken = default); } // Inputs to IJsBindingsWorkspaceService.RunAsync. @@ -40,8 +32,9 @@ internal sealed class JsBindingsOrchestrationContext public required DirectoryInfo LocalWinappDir { get; init; } public required DirectoryInfo NugetCacheDir { get; init; } - // (name → version) incl. transitive deps. null on the add path — derived - // from lockfile / transitive expansion. + // (name → version) incl. transitive deps. Populated by the init / restore + // flow before invoking RunAsync. Null forces the lockfile fast-path or + // live transitive expansion (used by tests and future external callers). public IReadOnlyDictionary? UsedVersions { get; init; } public bool EnsureRuntimeDependency { get; init; } = true; diff --git a/src/winapp-CLI/WinApp.Cli/Services/JsBindingsPresets.cs b/src/winapp-CLI/WinApp.Cli/Services/JsBindingsPresets.cs index 4bdeb5ba..69010d5a 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/JsBindingsPresets.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/JsBindingsPresets.cs @@ -11,69 +11,11 @@ internal enum WinmdPackageCategory Skip, } -// Named WinAppSDK slices selectable via --js-bindings-{preset} or -// `node jsbindings add --{preset}`. Maps to NuGet package IDs. +// Winmd / package categorization for JS bindings. Owns the static denylists +// (skip / ref-only) and merging them with user `jsBindings:` overrides from +// winapp.yaml. Shared by JsBindingsWorkspaceService and WinmdsLockfileService. internal static class JsBindingsPresets { - public static readonly IReadOnlyDictionary> KnownPresets = - new Dictionary>(StringComparer.OrdinalIgnoreCase) - { - ["ai"] = new[] - { - "Microsoft.WindowsAppSDK.AI", - }, - }; - - public static bool TryResolve(string presetName, out IReadOnlyList packageIds) - { - if (KnownPresets.TryGetValue(presetName, out var resolved)) - { - packageIds = resolved; - return true; - } - packageIds = Array.Empty(); - return false; - } - - public static string KnownPresetsDisplay() - { - return string.Join(", ", KnownPresets.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase)); - } - - // Union package IDs of multiple presets; dedup case-insensitively. - public static IReadOnlyList ResolveAndUnion(IEnumerable presetNames) - { - var result = new List(); - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var name in presetNames) - { - if (string.IsNullOrWhiteSpace(name)) - { - continue; - } - if (!KnownPresets.TryGetValue(name, out var packageIds)) - { - continue; - } - foreach (var p in packageIds) - { - if (seen.Add(p)) - { - result.Add(p); - } - } - } - return result; - } - - // "ai" → "--js-bindings-ai" (init flag) - public static string AliasFlagName(string presetName) => - $"--js-bindings-{presetName.ToLowerInvariant()}"; - - // "ai" → "--ai" (add sub-command flag) - public static string AddAliasFlagName(string presetName) => - $"--{presetName.ToLowerInvariant()}"; - // Built-in denylists; user `jsBindings` overrides layer on top. // RefOnly: own classes are undriveable but other packages reference them. diff --git a/src/winapp-CLI/WinApp.Cli/Services/JsBindingsWorkspaceService.cs b/src/winapp-CLI/WinApp.Cli/Services/JsBindingsWorkspaceService.cs index b9a84cb3..b3e22c25 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/JsBindingsWorkspaceService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/JsBindingsWorkspaceService.cs @@ -2,7 +2,6 @@ // Licensed under the MIT License. using Microsoft.Extensions.Logging; -using Spectre.Console; using System.Text.Json; using WinApp.Cli.ConsoleTasks; using WinApp.Cli.Helpers; @@ -20,10 +19,6 @@ internal sealed partial class JsBindingsWorkspaceService( IUserPackageJsonService userPackageJsonService, INpmWrapperVersionProvider npmWrapperVersionProvider, IPackageManagerDetector packageManagerDetector, - IConfigService configService, - IStatusService statusService, - IWinappDirectoryService winappDirectoryService, - IAnsiConsole ansiConsole, ILogger logger) : IJsBindingsWorkspaceService { public async Task RunAsync( @@ -379,304 +374,4 @@ internal static List MergeRefWinmds( } return result; } - - public async Task AddAsync(AddJsBindingsOptions options, CancellationToken cancellationToken = default) - { - configService.ConfigPath = new FileInfo(Path.Combine(options.ConfigDir.FullName, "winapp.yaml")); - - if (!configService.Exists()) - { - logger.LogError( - "{UISymbol} winapp.yaml not found at {ConfigPath}. Run 'npx winapp init' first to bootstrap a workspace. " - + "Tip: --config-dir resolves relative to the current directory — verify it points to the same workspace 'init' targeted.", - UiSymbols.Error, - configService.ConfigPath.FullName); - return 1; - } - - WinappConfig config; - try - { - config = configService.Load(); - } - catch (Exception ex) - { - logger.LogError(ex, - "{UISymbol} Failed to parse winapp.yaml at {ConfigPath}: {Message}", - UiSymbols.Error, - configService.ConfigPath.FullName, - ex.Message); - return 1; - } - - // Existing jsBindings? --force replaces; --use-defaults preserves - // (idempotent no-op); interactive prompts; non-interactive errors. - if (config.JsBindings is not null && !options.Force) - { - if (options.UseDefaults) - { - logger.LogInformation( - "{UISymbol} jsBindings already declared; preserving (use --force to patch).", - UiSymbols.Note); - return 0; - } - - bool overwrite; - try - { - overwrite = await ShowConfirmationPromptAsync( - "winapp.yaml already declares jsBindings. Overwrite?", - cancellationToken); - } - catch (OperationCanceledException) - { - throw; // Real user/parent cancellation — let it propagate. - } - catch (Exception) - { - logger.LogError( - "{UISymbol} winapp.yaml already declares jsBindings. Re-run with --force to patch (output and preset packages get overwritten; all other fields preserved). Pass --use-defaults to preserve and exit 0 instead.", - UiSymbols.Error); - return 1; - } - - if (!overwrite) - { - logger.LogInformation("{UISymbol} No changes; existing jsBindings preserved.", UiSymbols.Note); - return 0; - } - } - - // Build the (new or patched) jsBindings block. On --force we patch - // in place — only CLI-supplied fields (output, preset packages) - // overwrite; everything else survives. - var oldOutput = config.JsBindings?.Output; - var newJs = config.JsBindings ?? new JsBindingsConfig(); - if (!string.IsNullOrWhiteSpace(options.Output)) - { - newJs.Output = options.Output!.Trim(); - } - if (options.Presets is { Count: > 0 } presetNames) - { - var packageIds = JsBindingsPresets.ResolveAndUnion(presetNames); - if (packageIds.Count > 0) - { - newJs.Packages = new List(packageIds); - logger.LogDebug( - "{UISymbol} jsBindings presets [{Presets}] → packages=[{Packages}]", - UiSymbols.New, - string.Join(", ", presetNames), - string.Join(", ", packageIds)); - } - } - - // Validate the resolved output path BEFORE we touch yaml. - try - { - DynWinrtCodegenService.ResolveOutputDir(options.BaseDirectory, newJs.Output); - } - catch (InvalidOperationException ex) - { - logger.LogError("{UISymbol} Invalid jsBindings.output: {Reason}", UiSymbols.Error, ex.Message); - return 1; - } - - config.JsBindings = newJs; - - try - { - configService.SaveJsBindingsOnly(config); - } - catch (Exception ex) - { - logger.LogError(ex, - "{UISymbol} Failed to write winapp.yaml at {ConfigPath}: {Message}", - UiSymbols.Error, - configService.ConfigPath.FullName, - ex.Message); - return 1; - } - - logger.LogInformation("{UISymbol} Updated winapp.yaml with jsBindings block", UiSymbols.Save); - - var codegenExit = await statusService.ExecuteWithStatusAsync( - "Generating JS bindings", - async (taskContext, ct) => - { - var nugetCacheDir = nugetService.GetNuGetGlobalPackagesDir(); - var localWinappDir = winappDirectoryService.GetLocalWinappDirectory(options.BaseDirectory); - - var orchResult = await RunAsync( - new JsBindingsOrchestrationContext - { - JsBindingsConfig = config.JsBindings!, - WinappConfig = config, - WorkspaceDir = options.BaseDirectory, - LocalWinappDir = localWinappDir, - NugetCacheDir = nugetCacheDir, - UsedVersions = null, - }, - taskContext, - ct); - return (orchResult.ExitCode, orchResult.Message); - }, - cancellationToken); - - // Cleanup only after codegen succeeds. - if (codegenExit == 0 - && !string.IsNullOrEmpty(oldOutput) - && !string.Equals(oldOutput, newJs.Output, StringComparison.OrdinalIgnoreCase)) - { - try - { - var oldDir = DynWinrtCodegenService.ResolveOutputDir(options.BaseDirectory, oldOutput); - var newDir = DynWinrtCodegenService.ResolveOutputDir(options.BaseDirectory, newJs.Output); - if (oldDir.Exists - && !string.Equals(oldDir.FullName, newDir.FullName, StringComparison.OrdinalIgnoreCase) - && !IsNestedPath(oldDir.FullName, newDir.FullName) - && !IsNestedPath(newDir.FullName, oldDir.FullName)) - { - DynWinrtCodegenService.WipeOutputDirSafely(oldDir); - oldDir.Refresh(); - if (oldDir.Exists && !oldDir.EnumerateFileSystemInfos().Any()) - { - oldDir.Delete(); - } - logger.LogInformation( - "{UISymbol} Removed previous bindings dir {OldDir} (output: changed)", - UiSymbols.Trash, oldDir.FullName); - } - else if (oldDir.Exists - && (IsNestedPath(oldDir.FullName, newDir.FullName) || IsNestedPath(newDir.FullName, oldDir.FullName))) - { - // Nested paths — wiping old would wipe new (or vice versa). - // Skip cleanup so we never delete the bindings we just generated. - logger.LogInformation( - "{UISymbol} Previous bindings dir {OldDir} overlaps new output {NewDir}; skipping cleanup. Delete manually if no longer needed.", - UiSymbols.Note, oldDir.FullName, newDir.FullName); - } - } - catch (OperationCanceledException) - { - throw; - } - catch (InvalidOperationException ex) - { - logger.LogInformation( - "{UISymbol} Previous bindings dir not removed: {Reason}. Delete manually if no longer needed.", - UiSymbols.Note, ex.Message); - } - catch (Exception ex) - { - logger.LogWarning(ex, - "{UISymbol} Old output dir cleanup failed: {Reason}.", - UiSymbols.Warning, ex.Message); - } - } - - return codegenExit; - } - - public async Task GenerateAsync(GenerateJsBindingsOptions options, CancellationToken cancellationToken = default) - { - configService.ConfigPath = new FileInfo(Path.Combine(options.ConfigDir.FullName, "winapp.yaml")); - - if (!configService.Exists()) - { - logger.LogError( - "{UISymbol} winapp.yaml not found at {ConfigPath}. Run 'npx winapp init' first to bootstrap a workspace. " - + "Tip: --config-dir resolves relative to the current directory — verify it points to the same workspace 'init' targeted.", - UiSymbols.Error, - configService.ConfigPath.FullName); - return 1; - } - - WinappConfig config; - try - { - config = configService.Load(); - } - catch (Exception ex) - { - logger.LogError(ex, - "{UISymbol} Failed to parse winapp.yaml at {ConfigPath}: {Message}", - UiSymbols.Error, - configService.ConfigPath.FullName, - ex.Message); - return 1; - } - - if (config.JsBindings is null) - { - logger.LogError( - "{UISymbol} No jsBindings: block in winapp.yaml. Run 'npx winapp node jsbindings add' first to declare one.", - UiSymbols.Error); - return 1; - } - - try - { - DynWinrtCodegenService.ResolveOutputDir(options.BaseDirectory, config.JsBindings.Output); - } - catch (InvalidOperationException ex) - { - logger.LogError("{UISymbol} Invalid jsBindings.output: {Reason}", UiSymbols.Error, ex.Message); - return 1; - } - - var codegenExit = await statusService.ExecuteWithStatusAsync( - "Generating JS bindings", - async (taskContext, ct) => - { - var nugetCacheDir = nugetService.GetNuGetGlobalPackagesDir(); - var localWinappDir = winappDirectoryService.GetLocalWinappDirectory(options.BaseDirectory); - - var orchResult = await RunAsync( - new JsBindingsOrchestrationContext - { - JsBindingsConfig = config.JsBindings!, - WinappConfig = config, - WorkspaceDir = options.BaseDirectory, - LocalWinappDir = localWinappDir, - NugetCacheDir = nugetCacheDir, - UsedVersions = null, - // Read-only contract: `node jsbindings generate` is - // documented as a no-op on yaml AND package.json. - // The runtime dep is added by `node jsbindings add` - // and by `init --js-bindings`; re-adding it here - // would silently un-do a deliberate user removal. - EnsureRuntimeDependency = false, - }, - taskContext, - ct); - return (orchResult.ExitCode, orchResult.Message); - }, - cancellationToken); - - return codegenExit; - } - - private async Task ShowConfirmationPromptAsync(string prompt, CancellationToken cancellationToken) - { - var result = await ansiConsole.PromptAsync(new ConfirmationPrompt(prompt), cancellationToken); - ansiConsole.Cursor.MoveUp(); - ansiConsole.Write("\x1b[2K"); - ansiConsole.MarkupLine($"{prompt}: [underline]{(result ? "Yes" : "No")}[/]"); - return result; - } - - // True when `child` is at or below `parent` in the file system tree - // (case-insensitive on Windows). Used to skip cleanup of nested - // old/new output dirs where wiping one would wipe the other. - private static bool IsNestedPath(string parent, string child) - { - var p = parent.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - var c = child.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - if (string.Equals(p, c, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - var prefix = p + Path.DirectorySeparatorChar; - return c.StartsWith(prefix, StringComparison.OrdinalIgnoreCase); - } } diff --git a/src/winapp-CLI/WinApp.Cli/Services/WinappConfigDocument.cs b/src/winapp-CLI/WinApp.Cli/Services/WinappConfigDocument.cs index ad2a1e0b..6d1f7eaf 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/WinappConfigDocument.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/WinappConfigDocument.cs @@ -177,6 +177,21 @@ private static WinappConfig ParseInternal(string yaml) currentName = null; continue; } + // Top-level scalar: cppProjections: . Default true; only + // written by `winapp init` when the npm caller picks "JS only". + if (TryReadScalar(t, "cppProjections:", out var cppProjValue)) + { + if (TryParseBool(cppProjValue, out var b)) + { + cfg.CppProjections = b; + } + section = Section.None; + currentName = null; + jsList = JsListMode.None; + currentExtra = null; + inClassesList = false; + continue; + } // Accept `jsBindings:` followed by inline comment / trailing // whitespace — matches SpliceJsBindingsBlock's detection so // Load() and the splice can never disagree on whether the @@ -406,6 +421,29 @@ internal static bool TryReadScalar(string t, string prefix, out string value) return false; } + // YAML-style boolean tolerance: true/false/yes/no/on/off (case-insensitive). + internal static bool TryParseBool(string value, out bool result) + { + switch (value.Trim().ToLowerInvariant()) + { + case "true": + case "yes": + case "on": + case "1": + result = true; + return true; + case "false": + case "no": + case "off": + case "0": + result = false; + return true; + default: + result = false; + return false; + } + } + // Trims surrounding whitespace, strips an unquoted trailing `# comment`, // then strips a single pair of matching surrounding quotes. Mirrors what // a YAML parser would do for plain / single- / double-quoted scalars @@ -544,6 +582,14 @@ private static string Stringify(WinappConfig cfg) sb.AppendLine($" version: {QuoteScalar(p.Version)}"); } + // Only emit cppProjections when it diverges from the default (true) so + // existing yamls stay clean and round-trip unchanged. + if (!cfg.CppProjections) + { + sb.AppendLine(); + sb.AppendLine("cppProjections: false"); + } + if (cfg.JsBindings is { } js) { sb.AppendLine(); diff --git a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Init.cs b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Init.cs index 5ea8cefb..aee5021a 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Init.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Init.cs @@ -13,9 +13,9 @@ namespace WinApp.Cli.Services; // Owns the logic that decides whether we're "init" or "restore", loads or // scaffolds winapp.yaml, walks the user through SDK / manifest / dev-mode // prompts on first run, validates a .NET project's TargetFramework, and -// emits the default jsBindings: block when `--js-bindings` is supplied. -// Result tuple is consumed by SetupWorkspaceAsync to decide the rest of -// the flow. +// (for npm callers) prompts which bindings — C++ / JS/TS / Both — to wire +// into winapp.yaml. Result tuple is consumed by SetupWorkspaceAsync to +// decide the rest of the flow. internal partial class WorkspaceSetupService { private async Task<(int ReturnCode, WinappConfig? Config, bool HadExistingConfig, bool ShouldGenerateManifest, ManifestGenerationInfo? ManifestGenerationInfo, bool ShouldEnableDeveloperMode, string? RecommendedTfm)> InitializeConfigurationAsync(WorkspaceSetupOptions options, bool isDotNetProject, FileInfo? csprojFile, CancellationToken cancellationToken) @@ -50,7 +50,7 @@ internal partial class WorkspaceSetupService { config = configService.Load(); - if (config.Packages.Count == 0 && options.RequireExistingConfig) + if (config.Packages.Count == 0 && options.RequireExistingConfig && config.JsBindings is null) { logger.LogInformation("{UISymbol} winapp.yaml found but contains no packages. Nothing to restore.", UiSymbols.Note); shouldEnableDeveloperMode = await AskShouldEnableDeveloperModeAsync(options, cancellationToken); @@ -60,21 +60,6 @@ internal partial class WorkspaceSetupService var operation = options.RequireExistingConfig ? "Found" : "Found existing"; logger.LogDebug("{UISymbol} {Operation} winapp.yaml with {PackageCount} packages", UiSymbols.Package, operation, config.Packages.Count); - // Re-init hint: surface JS bindings capability for npm-shim users - // who haven't opted in (winget users can't use --js-bindings). - if (!options.RequireExistingConfig - && !options.AddJsBindings - && config.JsBindings is null - && string.Equals( - Environment.GetEnvironmentVariable("WINAPP_CLI_CALLER"), - "nodejs-package", - StringComparison.Ordinal)) - { - logger.LogInformation( - "{UISymbol} To add JS/TS bindings to this project, re-run: npx winapp init --js-bindings", - UiSymbols.Info); - } - if (!options.RequireExistingConfig && config.Packages.Count > 0) { logger.LogDebug("{UISymbol} Using pinned package versions from winapp.yaml unless overridden.", UiSymbols.Note); @@ -122,82 +107,47 @@ internal partial class WorkspaceSetupService } } - // Re-check after AskSdkInstallModeAsync: the interactive prompt - // can leave SdkInstallMode=None, which breaks --js-bindings. - if (options.AddJsBindings && options.SdkInstallMode == SdkInstallMode.None) + // npm-caller bindings prompt: ask whether to wire C++ projections, + // JS/TS bindings, or both into winapp.yaml. Fills options.AddJsBindings + // and options.SkipCppProjections so the rest of the flow knows what + // to generate. No-op when not running via the npm shim, or when an + // existing yaml already declares jsBindings:. + var bindingsKind = await AskBindingsKindAsync(options, config, cancellationToken); + options.AddJsBindings = bindingsKind is BindingsKind.JsOnly or BindingsKind.Both; + options.SkipCppProjections = bindingsKind == BindingsKind.JsOnly; + + // JS/TS bindings target Node/Electron hosts via dynwinrt; .NET projects + // already have first-class WinRT projections through CsWinRT and the + // codegen does not produce a .NET-consumable surface. Reject early + // rather than silently writing a jsBindings: block that would fail + // at codegen time. + if (options.AddJsBindings && isDotNetProject) { logger.LogError( - "{UISymbol} --js-bindings requires SDK packages but the SDK install mode was set to 'none'. " + - "Re-run without --js-bindings, or pick a non-'none' SDK mode (stable / preview / experimental).", + "{UISymbol} JS/TS bindings are not supported on .NET projects — the codegen targets Node/Electron via dynwinrt, and .NET projects already get WinRT via CsWinRT. " + + "Re-run from a non-.NET project, or omit JS bindings at the prompt.", UiSymbols.Error); return (1, config, hadExistingConfig, shouldGenerateManifest, manifestGenerationInfo, shouldEnableDeveloperMode, recommendedTfm); } - // --js-bindings: fill a default block when none exists; never overwrite. - if (options.AddJsBindings && config != null && config.JsBindings is not null) + // Re-check after AskSdkInstallModeAsync: the interactive prompt + // can leave SdkInstallMode=None, which breaks JS bindings. + if (options.AddJsBindings && options.SdkInstallMode == SdkInstallMode.None) { - // Warn when override flags are ignored because a block already exists. - var hasOverrides = !string.IsNullOrWhiteSpace(options.JsBindingsOutputOverride) - || !string.IsNullOrWhiteSpace(options.JsBindingsLangOverride) - || (options.JsBindingsPresets is { Count: > 0 }); - if (hasOverrides) - { - logger.LogWarning( - "{UISymbol} --js-bindings-output / --js-bindings-lang / --js-bindings-{{preset}} are " + - "ignored because winapp.yaml already declares a jsBindings block. " + - "Use 'npx winapp node jsbindings add --force' to overwrite specific fields.", - UiSymbols.Warning); - } + logger.LogError( + "{UISymbol} JS/TS bindings need SDK packages, but the SDK install mode was set to 'none'. " + + "Re-run and pick a non-'none' SDK mode (stable / preview / experimental), or pick 'C++ only' at the bindings prompt.", + UiSymbols.Error); + return (1, config, hadExistingConfig, shouldGenerateManifest, manifestGenerationInfo, shouldEnableDeveloperMode, recommendedTfm); } + + // Inject a default jsBindings: block (empty packages: ⇒ all WinAppSDK) + // when the prompt opted in and the existing yaml hasn't declared one. if (options.AddJsBindings && config != null && config.JsBindings is null) { - var jsCfg = new JsBindingsConfig(); - if (!string.IsNullOrWhiteSpace(options.JsBindingsOutputOverride)) - { - jsCfg.Output = options.JsBindingsOutputOverride!.Trim(); - } - if (!string.IsNullOrWhiteSpace(options.JsBindingsLangOverride)) - { - jsCfg.Lang = options.JsBindingsLangOverride!.Trim(); - } - - // Validate the resolved output path before persisting. - try - { - DynWinrtCodegenService.ResolveOutputDir(options.BaseDirectory, jsCfg.Output); - } - catch (InvalidOperationException ex) - { - logger.LogError( - "{UISymbol} Invalid --js-bindings-output: {Reason}", - UiSymbols.Error, ex.Message); - return (1, config, hadExistingConfig, shouldGenerateManifest, manifestGenerationInfo, shouldEnableDeveloperMode, recommendedTfm); - } - if (options.JsBindingsPresets is { Count: > 0 } presetNames) - { - var packageIds = JsBindingsPresets.ResolveAndUnion(presetNames); - if (packageIds.Count > 0) - { - jsCfg.Packages = new List(packageIds); - logger.LogDebug( - "{UISymbol} jsBindings presets [{Presets}] → packages=[{Packages}]", - UiSymbols.New, - string.Join(", ", presetNames), - string.Join(", ", packageIds)); - } - else - { - // Defensive: InitCommand validates preset names upstream. - logger.LogWarning( - "{UISymbol} jsBindings presets [{Presets}] resolved to no prefixes; ignoring (known: {Known}).", - UiSymbols.Warning, - string.Join(", ", presetNames), - JsBindingsPresets.KnownPresetsDisplay()); - } - } - config.JsBindings = jsCfg; + config.JsBindings = new JsBindingsConfig(); logger.LogDebug( - "{UISymbol} --js-bindings: added default jsBindings block (lang={Lang}, output={Output})", + "{UISymbol} Added default jsBindings block (lang={Lang}, output={Output}); empty packages ⇒ full WinAppSDK.", UiSymbols.New, config.JsBindings.Lang, config.JsBindings.Output); @@ -207,6 +157,14 @@ internal partial class WorkspaceSetupService // it here would leave package.json mutated if codegen failed. } + // Persist cppProjections: false when the JS-only choice diverges from + // the model default (true). Init's later save path round-trips this + // field through WinappConfigDocument. + if (options.SkipCppProjections && config != null) + { + config.CppProjections = false; + } + // .NET: Validate TargetFramework (interactive) if (isDotNetProject && csprojFile != null) { diff --git a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Options.cs b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Options.cs index 1644361d..ec8e3757 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Options.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Options.cs @@ -18,42 +18,12 @@ internal class WorkspaceSetupOptions public bool ForceLatestBuildTools { get; set; } public bool ConfigOnly { get; set; } - // Enable JS/TS bindings generation in Step 5.5 of setup. + // Enable JS/TS bindings generation in Step 5.5 of setup. Populated by the + // npm-caller prompt in WorkspaceSetupService; no CLI flag exposes this. public bool AddJsBindings { get; set; } - // CLI override for jsBindings.output. - public string? JsBindingsOutputOverride { get; set; } - - // CLI override for jsBindings.lang. - public string? JsBindingsLangOverride { get; set; } - - // Preset names from JsBindingsPresets — unioned into jsBindings.packages. - public IReadOnlyList? JsBindingsPresets { get; set; } -} - -// Params for AddJsBindingsAsync. -internal class AddJsBindingsOptions -{ - public required DirectoryInfo BaseDirectory { get; set; } - public required DirectoryInfo ConfigDir { get; set; } - - // CLI override for jsBindings.output. - public string? Output { get; set; } - - // Preset names from JsBindingsPresets. - public IReadOnlyList? Presets { get; set; } - - // Patch an existing jsBindings: block without prompting. - public bool Force { get; set; } - - // Preserve an existing jsBindings: block and exit 0 without prompting. - // Mutually exclusive with Force. - public bool UseDefaults { get; set; } -} - -// Params for the read-only `node jsbindings generate` flow. -internal class GenerateJsBindingsOptions -{ - public required DirectoryInfo BaseDirectory { get; set; } - public required DirectoryInfo ConfigDir { get; set; } + // Skip cppwinrt headers/libs/runtimes/projection generation. Populated by the + // npm-caller prompt when the user picks "JS only" so pure-Node projects don't + // pay the ~130MB / ~20s C++ projection cost. + public bool SkipCppProjections { get; set; } } diff --git a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Prompts.cs b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Prompts.cs index d72499c5..26d08a99 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Prompts.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Prompts.cs @@ -237,4 +237,80 @@ await ansiConsole.Status() logger.LogInformation("{Message}", message); return await work(cancellationToken); } + + // Result of the npm-caller bindings prompt. + private enum BindingsKind + { + CppOnly, + JsOnly, + Both, + } + + // Asks the user (npm caller only) which bindings to generate for this + // workspace: C++ projections, JS/TS bindings, or both. Defaults to Both + // under --use-defaults. Returns CppOnly (the historical default) for + // non-npm callers so winget / standalone-CLI users see no behavior change. + private async Task AskBindingsKindAsync(WorkspaceSetupOptions options, WinappConfig? existingConfig, CancellationToken cancellationToken) + { + // Restore (winapp restore) never re-prompts: it respects whatever the + // existing yaml already declares. + if (options.RequireExistingConfig) + { + return BindingsKindFromConfig(existingConfig); + } + + // Standalone CLI (winget / native binary) keeps its current C++ default. + var caller = Environment.GetEnvironmentVariable("WINAPP_CLI_CALLER"); + if (!string.Equals(caller, "nodejs-package", StringComparison.Ordinal)) + { + return BindingsKind.CppOnly; + } + + // Existing yaml that already declares jsBindings: — don't change the + // user's earlier choice. Map it back to a kind so callers can still + // gate on AddJsBindings / SkipCppProjections. + if (existingConfig?.JsBindings is not null) + { + return BindingsKindFromConfig(existingConfig); + } + + // Non-interactive: default to Both so `npx winapp init --use-defaults` + // (sample tests, CI) wires up everything the npm wrapper enables. + if (options.UseDefaults) + { + return BindingsKind.Both; + } + + var choices = new[] + { + "Both C++ and JS/TS bindings (default)", + "JS/TS bindings only", + "C++ projections only", + }; + + ansiConsole.WriteLine("Select which bindings to generate:"); + var pick = await ansiConsole.PromptAsync( + new SelectionPrompt().AddChoices(choices), + cancellationToken); + + ansiConsole.Cursor.MoveUp(); + ansiConsole.Write("\x1b[2K"); // Clear line + ansiConsole.MarkupLine($"Bindings: [underline]{Markup.Remove(pick)}[/]"); + + return pick switch + { + "JS/TS bindings only" => BindingsKind.JsOnly, + "C++ projections only" => BindingsKind.CppOnly, + _ => BindingsKind.Both, + }; + } + + private static BindingsKind BindingsKindFromConfig(WinappConfig? config) + { + if (config?.JsBindings is null) + { + return BindingsKind.CppOnly; + } + return config.CppProjections ? BindingsKind.Both : BindingsKind.JsOnly; + } } diff --git a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.cs b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.cs index b8b891c3..a6598c95 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.cs @@ -12,8 +12,8 @@ namespace WinApp.Cli.Services; // Shared service for setting up winapp workspaces. Split into partials: // - this file: orchestration (SetupWorkspaceAsync, init/restore flow, JS bindings step glue) -// - WorkspaceSetupService.Options.cs: option DTOs (WorkspaceSetupOptions, AddJsBindingsOptions, GenerateJsBindingsOptions) -// - WorkspaceSetupService.Prompts.cs: Spectre.Console prompts (SDK choice, manifest, dev mode, .csproj picker) +// - WorkspaceSetupService.Options.cs: option DTO (WorkspaceSetupOptions) +// - WorkspaceSetupService.Prompts.cs: Spectre.Console prompts (SDK choice, manifest, dev mode, .csproj picker, bindings kind) // - WorkspaceSetupService.Msix.cs: Windows App SDK runtime MSIX install / NuGet-cache discovery internal partial class WorkspaceSetupService( IConfigService configService, @@ -40,17 +40,6 @@ public async Task SetupWorkspaceAsync(WorkspaceSetupOptions options, Cancel { configService.ConfigPath = new FileInfo(Path.Combine(options.ConfigDir.FullName, "winapp.yaml")); - // --js-bindings needs installed SDK packages; reject --setup-sdks none. - if (options.AddJsBindings && options.SdkInstallMode == SdkInstallMode.None) - { - logger.LogError( - "{UISymbol} --js-bindings requires SDK packages to be installed (it walks the NuGet cache for .winmd files), but --setup-sdks none was specified. " + - "Either drop --js-bindings here and add it later via 'npx winapp node jsbindings add' after restoring SDKs, " + - "or change --setup-sdks to a value other than none (stable / preview / experimental).", - UiSymbols.Error); - return 1; - } - // Detect .NET project (.csproj) in the base directory FileInfo? csprojFile = null; bool isDotNetProject = false; @@ -73,17 +62,6 @@ public async Task SetupWorkspaceAsync(WorkspaceSetupOptions options, Cancel return 1; } - // --js-bindings is unsupported on .NET projects (no .winapp/ workspace). - if (isDotNetProject && options.AddJsBindings) - { - logger.LogError( - "{UISymbol} --js-bindings is not supported for .NET (.csproj) projects yet. " + - "JS/TS bindings target native / Node-hosted apps. Run 'winapp init' (without --js-bindings) " + - "on the .NET project, and use 'winapp init --js-bindings' from your Node / Electron host instead.", - UiSymbols.Error); - return 1; - } - // Configuration / prompting phase bool hadExistingConfig; WinappConfig? config; @@ -98,36 +76,6 @@ public async Task SetupWorkspaceAsync(WorkspaceSetupOptions options, Cancel return initializationResult; } - // M2 (round-6): restore on a jsbindings-only workspace (no packages:, - // just jsBindings:) short-circuits the SDK install pipeline and - // forwards to the codegen-regen path. Without this guard the flow - // falls through to InstallPackagesAsync with the default SDK_PACKAGES - // set and then runs cppwinrt — neither of which the user asked for. - if (options.RequireExistingConfig - && !isDotNetProject - && config is not null - && config.Packages.Count == 0 - && config.JsBindings is not null) - { - logger.LogInformation( - "{UISymbol} winapp.yaml has no packages: but declares jsBindings: — regenerating JS bindings.", - UiSymbols.Note); - var generateExit = await jsBindingsWorkspaceService.GenerateAsync( - new GenerateJsBindingsOptions - { - BaseDirectory = options.BaseDirectory, - ConfigDir = options.ConfigDir, - }, - cancellationToken); - if (generateExit == 0) - { - // Parity with init --js-bindings: keep @microsoft/dynwinrt - // wired into package.json after a fresh clone restore. - jsBindingsWorkspaceService.EnsureRuntimeDependencyAndPrintHint(options.BaseDirectory); - } - return generateExit; - } - // Handle config-only mode: just create/validate config file and exit (only for non-.NET path) if (!isDotNetProject && options.ConfigOnly) { @@ -145,12 +93,22 @@ public async Task SetupWorkspaceAsync(WorkspaceSetupOptions options, Cancel } } - // Persist re-init's freshly-injected jsBindings even under - // --config-only (otherwise the save would be skipped). + // Persist the prompt's freshly-injected jsBindings (and any + // cppProjections override) even under --config-only. if (options.AddJsBindings && config.JsBindings is not null) { - // Splice-save preserves user comments + unknown fields. - configService.SaveJsBindingsOnly(config); + if (options.SkipCppProjections) + { + // SaveJsBindingsOnly only splices jsBindings; full-save + // is the simplest way to round-trip cppProjections too + // (loses comments — acceptable trade-off for a niche + // --config-only + JS-only path). + configService.Save(config); + } + else + { + configService.SaveJsBindingsOnly(config); + } logger.LogDebug("{UISymbol} Persisted updated configuration with jsBindings → {ConfigPath}", UiSymbols.Save, configService.ConfigPath); } } @@ -178,8 +136,9 @@ public async Task SetupWorkspaceAsync(WorkspaceSetupOptions options, Cancel var finalConfig = new WinappConfig { - // Preserve JsBindings across re-init. + // Preserve JsBindings + CppProjections across re-init. JsBindings = config?.JsBindings, + CppProjections = config?.CppProjections ?? true, }; foreach (var kvp in defaultVersions) { @@ -567,65 +526,76 @@ await taskContext.AddSubTaskAsync("Configuring developer mode", async (taskConte return (1, "Error installing packages."); } - // Step 5: Run cppwinrt and set up projections - var cppWinrtExe = cppWinrtService.FindCppWinrtExe(nugetCacheDir, usedVersions); - if (cppWinrtExe is null) + // Step 5: Run cppwinrt and set up projections. + // Gated by config.CppProjections (default true): JS-only workspaces + // (config.CppProjections == false) skip cppwinrt + headers/libs/runtimes + // but still need .winmd discovery + lockfile for the JS bindings step. + var generateCppProjections = config?.CppProjections != false; + FileInfo? cppWinrtExe = null; + if (generateCppProjections) { - return (1, "cppwinrt.exe not found in installed packages."); - } + cppWinrtExe = cppWinrtService.FindCppWinrtExe(nugetCacheDir, usedVersions); + if (cppWinrtExe is null) + { + return (1, "cppwinrt.exe not found in installed packages."); + } - taskContext.AddDebugMessage($"{UiSymbols.Tools} Using cppwinrt tool → {cppWinrtExe}"); + taskContext.AddDebugMessage($"{UiSymbols.Tools} Using cppwinrt tool → {cppWinrtExe}"); - // Copy headers, libs, runtimes - taskContext.UpdateSubStatus("Copying headers"); - packageLayoutService.CopyIncludesFromPackages(nugetCacheDir, includeOut, usedVersions); - taskContext.AddDebugMessage($"{UiSymbols.Check} Headers ready → {includeOut}"); + // Copy headers, libs, runtimes + taskContext.UpdateSubStatus("Copying headers"); + packageLayoutService.CopyIncludesFromPackages(nugetCacheDir, includeOut, usedVersions); + taskContext.AddDebugMessage($"{UiSymbols.Check} Headers ready → {includeOut}"); - taskContext.UpdateSubStatus("Copying import libraries"); - packageLayoutService.CopyLibsAllArch(nugetCacheDir, libRoot, usedVersions); - var libArchs = libRoot.Exists ? string.Join(", ", libRoot.EnumerateDirectories().Select(d => d.Name)) : "(none)"; - taskContext.AddDebugMessage($"{UiSymbols.Books} Import libs ready for archs: {libArchs}"); + taskContext.UpdateSubStatus("Copying import libraries"); + packageLayoutService.CopyLibsAllArch(nugetCacheDir, libRoot, usedVersions); + var libArchs = libRoot.Exists ? string.Join(", ", libRoot.EnumerateDirectories().Select(d => d.Name)) : "(none)"; + taskContext.AddDebugMessage($"{UiSymbols.Books} Import libs ready for archs: {libArchs}"); - taskContext.UpdateSubStatus("Copying runtime binaries"); - packageLayoutService.CopyRuntimesAllArch(nugetCacheDir, binRoot, usedVersions); - var binArchs = binRoot.Exists ? string.Join(", ", binRoot.EnumerateDirectories().Select(d => d.Name)) : "(none)"; - taskContext.AddDebugMessage($"{UiSymbols.Check} Runtime binaries ready for archs: {binArchs}"); + taskContext.UpdateSubStatus("Copying runtime binaries"); + packageLayoutService.CopyRuntimesAllArch(nugetCacheDir, binRoot, usedVersions); + var binArchs = binRoot.Exists ? string.Join(", ", binRoot.EnumerateDirectories().Select(d => d.Name)) : "(none)"; + taskContext.AddDebugMessage($"{UiSymbols.Check} Runtime binaries ready for archs: {binArchs}"); - // Copy Windows App SDK license - try - { - if (usedVersions.TryGetValue(BuildToolsService.WINAPP_SDK_PACKAGE, out var wasdkVersion)) + // Copy Windows App SDK license + try { - var pkgDir = nugetService.GetNuGetPackageDir(BuildToolsService.WINAPP_SDK_PACKAGE, wasdkVersion); - var licenseSrc = Path.Combine(pkgDir.FullName, "license.txt"); - if (File.Exists(licenseSrc)) + if (usedVersions.TryGetValue(BuildToolsService.WINAPP_SDK_PACKAGE, out var wasdkVersion)) { - var shareDir = Path.Combine(localWinappDir.FullName, "share", BuildToolsService.WINAPP_SDK_PACKAGE); - Directory.CreateDirectory(shareDir); - var licenseDst = Path.Combine(shareDir, "copyright"); - File.Copy(licenseSrc, licenseDst, overwrite: true); - taskContext.AddDebugMessage($"{UiSymbols.Check} License copied → {licenseDst}"); + var pkgDir = nugetService.GetNuGetPackageDir(BuildToolsService.WINAPP_SDK_PACKAGE, wasdkVersion); + var licenseSrc = Path.Combine(pkgDir.FullName, "license.txt"); + if (File.Exists(licenseSrc)) + { + var shareDir = Path.Combine(localWinappDir.FullName, "share", BuildToolsService.WINAPP_SDK_PACKAGE); + Directory.CreateDirectory(shareDir); + var licenseDst = Path.Combine(shareDir, "copyright"); + File.Copy(licenseSrc, licenseDst, overwrite: true); + taskContext.AddDebugMessage($"{UiSymbols.Check} License copied → {licenseDst}"); + } } } + catch (Exception ex) + { + taskContext.AddDebugMessage($"{UiSymbols.Note} Failed to copy license: {ex.Message}"); + } } - catch (Exception ex) + else { - taskContext.AddDebugMessage($"{UiSymbols.Note} Failed to copy license: {ex.Message}"); + taskContext.AddDebugMessage($"{UiSymbols.Skip} cppProjections: false → skipping cppwinrt + headers/libs/runtimes (JS-only workspace)."); } - // Collect winmd inputs and run cppwinrt + // Collect winmd inputs (unconditional: JS bindings need the lockfile too). taskContext.UpdateSubStatus("Searching for .winmd metadata"); var winmds = packageLayoutService.FindWinmds(nugetCacheDir, usedVersions).ToList(); taskContext.AddDebugMessage($"{UiSymbols.Search} Found {winmds.Count} .winmd"); if (winmds.Count == 0) { - return (2, "No .winmd files found for C++/WinRT projection."); + return (2, "No .winmd files found in installed SDK packages."); } - // Persist the lockfile so subsequent `node jsbindings add` - // can skip re-globbing / re-fetching nuspecs. Hash source - // must match what lands in winapp.yaml (SDK_PACKAGES-filtered - // for fresh init, config.Packages for restore). + // Persist the lockfile so the JS bindings step can skip re-globbing + // / re-fetching nuspecs. Hash source must match what lands in + // winapp.yaml (SDK_PACKAGES-filtered for fresh init, config.Packages for restore). var yamlHash = (options.RequireExistingConfig && config?.Packages.Count > 0) ? YamlPackagesHasher.Compute(config.Packages) : YamlPackagesHasher.ComputeFromVersions(usedVersions @@ -633,10 +603,13 @@ await taskContext.AddSubTaskAsync("Configuring developer mode", async (taskConte await winmdsLockfileService.WriteAsync( localWinappDir, usedVersions, winmds, nugetCacheDir, yamlHash, cancellationToken); - // Run cppwinrt - taskContext.UpdateSubStatus("Generating C++/WinRT projections"); - await cppWinrtService.RunWithRspAsync(cppWinrtExe, winmds, includeOut, localWinappDir, taskContext, cancellationToken: cancellationToken); - taskContext.AddDebugMessage($"{UiSymbols.Check} C++/WinRT headers generated → {includeOut}"); + if (generateCppProjections) + { + // Run cppwinrt + taskContext.UpdateSubStatus("Generating C++/WinRT projections"); + await cppWinrtService.RunWithRspAsync(cppWinrtExe!, winmds, includeOut, localWinappDir, taskContext, cancellationToken: cancellationToken); + taskContext.AddDebugMessage($"{UiSymbols.Check} C++/WinRT headers generated → {includeOut}"); + } partialResult = await taskContext.AddSubTaskAsync("Setting up tools", async (taskContext, cancellationToken) => { @@ -671,7 +644,9 @@ await winmdsLockfileService.WriteAsync( return partialResult; } - return (0, "SDK and Windows App SDK packages downloaded and C++ headers generated in [underline].winapp[/]"); + return (0, generateCppProjections + ? "SDK and Windows App SDK packages downloaded and C++ headers generated in [underline].winapp[/]" + : "SDK and Windows App SDK packages downloaded in [underline].winapp[/] (cppProjections: false → C++ headers skipped)"); }, cancellationToken); if (partialResult.Item1 != 0) @@ -766,8 +741,9 @@ await taskContext.AddSubTaskAsync("Saving configuration", (taskContext, cancella // Setup: Save winapp.yaml with used versions var finalConfig = new WinappConfig { - // Preserve JsBindings so the persisted yaml round-trips. + // Preserve JsBindings + CppProjections so the persisted yaml round-trips. JsBindings = config?.JsBindings, + CppProjections = config?.CppProjections ?? true, }; // only from SDK_PACKAGES var versionsToSave = usedVersions diff --git a/src/winapp-VSC/README.md b/src/winapp-VSC/README.md index 74d91a0a..3205319a 100644 --- a/src/winapp-VSC/README.md +++ b/src/winapp-VSC/README.md @@ -42,7 +42,7 @@ All commands are accessible from the Command Palette (`Ctrl+Shift+P`). Type **Wi | **WinApp: Run SDK Tool** | Run Windows SDK tools (`makeappx`, `signtool`, `mt`, `makepri`) with custom arguments. | | **WinApp: Get WinApp Path** | Show paths to installed SDK components. | -> **Note:** This extension exposes the native winapp CLI commands listed above. Node.js–specific subcommands provided by the [`@microsoft/winappcli` npm package](https://www.npmjs.com/package/@microsoft/winappcli) (`winapp node create-addon`, `winapp node jsbindings …`, etc.) are intentionally not surfaced in the Command Palette — install and use the npm package directly for those workflows. +> **Note:** This extension exposes the native winapp CLI commands listed above. Node.js–specific subcommands provided by the [`@microsoft/winappcli` npm package](https://www.npmjs.com/package/@microsoft/winappcli) (`winapp node create-addon`, etc.) are intentionally not surfaced in the Command Palette — install and use the npm package directly for those workflows. ### Integrated Debugging diff --git a/src/winapp-npm/README.md b/src/winapp-npm/README.md index 012aadfd..4aca8c9f 100644 --- a/src/winapp-npm/README.md +++ b/src/winapp-npm/README.md @@ -40,10 +40,8 @@ npx winapp --help **Setup Commands:** - [`init`](https://github.com/microsoft/WinAppCli/blob/main/docs/usage.md#init) - Initialize project with Windows SDK and App SDK -- [`restore`](https://github.com/microsoft/WinAppCli/blob/main/docs/usage.md#restore) - Restore packages and dependencies +- [`restore`](https://github.com/microsoft/WinAppCli/blob/main/docs/usage.md#restore) - Restore packages and dependencies (also runs the bindings step when `jsBindings:` is declared in `winapp.yaml`) - [`update`](https://github.com/microsoft/WinAppCli/blob/main/docs/usage.md#update) - Update packages and dependencies to latest versions -- [`node jsbindings add`](https://github.com/microsoft/WinAppCli/blob/main/docs/usage.md#node-jsbindings-add) - Add typed JS/TypeScript WinRT bindings to an existing workspace -- [`node jsbindings generate`](https://github.com/microsoft/WinAppCli/blob/main/docs/usage.md#node-jsbindings-generate) - Re-run codegen against an existing `jsBindings:` block (no yaml mutation) **App Identity & Debugging:** diff --git a/src/winapp-npm/scripts/generate-commands.mjs b/src/winapp-npm/scripts/generate-commands.mjs index f7a7f6cd..28475717 100644 --- a/src/winapp-npm/scripts/generate-commands.mjs +++ b/src/winapp-npm/scripts/generate-commands.mjs @@ -363,14 +363,9 @@ function generate(schema) { // --------------------------------------------------------------------------- const FN_NAME_OVERRIDES = { 'package': 'packageApp', // `package` is a TS reserved-ish word - 'node jsbindings add': 'nodeJsbindingsAdd', // canonical camelCase - 'node jsbindings generate': 'nodeJsbindingsGenerate', }; -const IFACE_NAME_OVERRIDES = { - 'node jsbindings add': 'NodeJsbindingsAddOptions', - 'node jsbindings generate': 'NodeJsbindingsGenerateOptions', -}; +const IFACE_NAME_OVERRIDES = {}; function getFunctionName(cmdPath) { const key = cmdPath.join(' '); diff --git a/src/winapp-npm/src/cli.ts b/src/winapp-npm/src/cli.ts index 2c79b611..3b9f804f 100644 --- a/src/winapp-npm/src/cli.ts +++ b/src/winapp-npm/src/cli.ts @@ -86,17 +86,7 @@ async function handleNodeCommand(command: string, args: string[]): Promise // Node.js wrapper-only commands that should appear in completions const NODE_WRAPPER_COMMANDS = ['node']; -// `js-bindings` is a kebab-case alias for `jsbindings` exposed by the -// native CLI (JsBindingsCommand.Aliases); keep the wrapper in sync so -// users following the kebab-case convention from the init flag -// (`--js-bindings`) don't get rejected with "Unknown node subcommand". -const NODE_SUBCOMMANDS = [ - 'jsbindings', - 'js-bindings', - 'create-addon', - 'add-electron-debug-identity', - 'clear-electron-debug-identity', -]; +const NODE_SUBCOMMANDS = ['create-addon', 'add-electron-debug-identity', 'clear-electron-debug-identity']; /** * Handle completion requests by forwarding to the native CLI and augmenting @@ -214,17 +204,12 @@ async function showCombinedHelp(): Promise { console.log(''); console.log('Node.js Subcommands:'); console.log(' node create-addon Generate native addon files for Electron'); - console.log(' node jsbindings add Edit winapp.yaml to declare JS/TS bindings'); - console.log(' node jsbindings generate Regenerate JS/TS bindings from winapp.yaml'); console.log(' node add-electron-debug-identity Add package identity to Electron debug process'); console.log(' node clear-electron-debug-identity Remove package identity from Electron debug process'); - console.log(' (jsbindings is also spelled js-bindings — both forms work.)'); console.log(''); console.log('Examples:'); console.log(` ${CLI_NAME} node create-addon --name myAddon`); console.log(` ${CLI_NAME} node create-addon --template cs --name myAddon`); - console.log(` ${CLI_NAME} node jsbindings add --ai`); - console.log(` ${CLI_NAME} node jsbindings generate`); console.log(` ${CLI_NAME} node add-electron-debug-identity`); console.log(` ${CLI_NAME} node clear-electron-debug-identity`); } @@ -282,16 +267,11 @@ async function handleNode(args: string[]): Promise { console.log('Node.js-specific commands'); console.log(''); console.log('Subcommands:'); - console.log(' jsbindings add Add a jsBindings: block to winapp.yaml + run codegen'); - console.log(' jsbindings generate Re-run codegen against the existing jsBindings: block'); console.log(' create-addon Generate native addon files for Electron'); console.log(' add-electron-debug-identity Add package identity to Electron debug process'); console.log(' clear-electron-debug-identity Remove package identity from Electron debug process'); - console.log(' (jsbindings is also spelled js-bindings — both forms work.)'); console.log(''); console.log('Examples:'); - console.log(` ${CLI_NAME} node jsbindings add --ai`); - console.log(` ${CLI_NAME} node jsbindings generate`); console.log(` ${CLI_NAME} node create-addon --help`); console.log(` ${CLI_NAME} node create-addon --name myAddon`); console.log(` ${CLI_NAME} node create-addon --name myCsAddon --template cs`); @@ -318,16 +298,6 @@ async function handleNode(args: string[]): Promise { await handleClearElectronDebugIdentity(subcommandArgs); break; - case 'jsbindings': - case 'js-bindings': - // Native-CLI sub-command tree (`node jsbindings add` / `... generate`). - // Forward the full argv (including the leading `node`) to the .NET CLI. - // Both `jsbindings` and `js-bindings` (kebab-case alias matching the - // `--js-bindings` init flag) are forwarded unchanged — the native CLI - // accepts both via JsBindingsCommand.Aliases. - await callWinappCli(['node', ...args], { exitOnError: true }); - break; - default: console.error(`Unknown node subcommand: ${subcommand}`); console.error(`Run "${CLI_NAME} node" for available subcommands.`); diff --git a/src/winapp-npm/src/winapp-commands.ts b/src/winapp-npm/src/winapp-commands.ts index 7dd613f7..e9164287 100644 --- a/src/winapp-npm/src/winapp-commands.ts +++ b/src/winapp-npm/src/winapp-commands.ts @@ -247,14 +247,6 @@ export interface InitOptions extends CommonOptions { configOnly?: boolean; /** Don't use configuration file for version management */ ignoreConfig?: boolean; - /** Generate JS/TS bindings via dynwinrt-codegen on top of the standard init flow. Adds a 'jsBindings:' block to winapp.yaml so the binding generator runs as part of init/restore. Only available when invoked via the \@microsoft/winappcli npm package (npx winapp init --js-bindings). */ - jsBindings?: boolean; - /** Generate bindings for the 'ai' slice of the SDK. Implies --js-bindings (no need to pass it separately). For a custom slice that no preset covers, edit winapp.yaml and write your own packages: list under jsBindings. Known presets: ai. */ - jsBindingsAi?: boolean; - /** Override the JS bindings language. Currently only 'js' is supported (which emits both .js and .d.ts). Reserved for forward-compat; see --js-bindings-output for activation rules. */ - jsBindingsLang?: string; - /** Override the output directory for generated JS/TS bindings (relative to workspace, default 'bindings/winrt'). Only takes effect together with --js-bindings on a fresh init; ignored on re-init when winapp.yaml already declares jsBindings:. */ - jsBindingsOutput?: string; /** Don't update .gitignore file */ noGitignore?: boolean; /** SDK installation mode: 'stable' (default), 'preview', 'experimental', or 'none' (skip SDK installation) */ @@ -264,7 +256,7 @@ export interface InitOptions extends CommonOptions { } /** - * Start here for initializing a Windows app with required setup. Sets up everything needed for Windows app development: creates Package.appxmanifest with default assets, downloads Windows SDK and Windows App SDK packages, and generates projections. When SDK packages are managed (--setup-sdks stable/preview/experimental), also creates winapp.yaml to pin versions for 'restore'/'update'; with --setup-sdks none (e.g., for Rust/Tauri projects that bring their own SDK bindings), no winapp.yaml is created. Interactive by default (use --use-defaults to skip prompts). Use 'restore' instead if you cloned a repo that already has winapp.yaml. Use 'manifest generate' if you only need a manifest, or 'cert generate' if you need a development certificate for code signing. + * Start here for initializing a Windows app with required setup. Sets up everything needed for Windows app development: creates Package.appxmanifest with default assets, downloads Windows SDK and Windows App SDK packages, and generates projections. When SDK packages are managed (--setup-sdks stable/preview/experimental), also creates winapp.yaml to pin versions for 'restore'/'update'; with --setup-sdks none (e.g., for Rust/Tauri projects that bring their own SDK bindings), no winapp.yaml is created. When invoked via the \@microsoft/winappcli npm package (npx winapp init), additionally asks whether to generate C++ projections, JS/TS bindings, or both. Interactive by default (use --use-defaults to skip prompts). Use 'restore' instead if you cloned a repo that already has winapp.yaml. Use 'manifest generate' if you only need a manifest, or 'cert generate' if you need a development certificate for code signing. */ export async function init(options: InitOptions = {}): Promise { const args: string[] = ['init']; @@ -272,10 +264,6 @@ export async function init(options: InitOptions = {}): Promise { if (options.configDir) args.push('--config-dir', options.configDir); if (options.configOnly) args.push('--config-only'); if (options.ignoreConfig) args.push('--ignore-config'); - if (options.jsBindings) args.push('--js-bindings'); - if (options.jsBindingsAi) args.push('--js-bindings-ai'); - if (options.jsBindingsLang) args.push('--js-bindings-lang', options.jsBindingsLang); - if (options.jsBindingsOutput) args.push('--js-bindings-output', options.jsBindingsOutput); if (options.noGitignore) args.push('--no-gitignore'); if (options.setupSdks) args.push('--setup-sdks', options.setupSdks); if (options.useDefaults) args.push('--use-defaults'); @@ -372,60 +360,6 @@ export async function manifestUpdateAssets(options: ManifestUpdateAssetsOptions) return execCommand(args, options); } -// --------------------------------------------------------------------------- -// node jsbindings add -// --------------------------------------------------------------------------- - -export interface NodeJsbindingsAddOptions extends CommonOptions { - /** Base/root directory for the winapp workspace (default: current directory) */ - baseDirectory?: string; - /** Generate bindings for the 'ai' slice of the SDK only. For a custom slice that no preset covers, edit winapp.yaml's jsBindings.packages after adding. Known presets: ai. */ - ai?: boolean; - /** Directory containing winapp.yaml (default: base-directory) */ - configDir?: string; - /** Patch an existing jsBindings: block without prompting. Overwrites only output and (when a preset like --ai is supplied) the packages list; all other fields are preserved. Without --force the command refuses to clobber a pre-existing block (interactive: prompts; non-interactive: errors). */ - force?: boolean; - /** Output directory for generated JS/TS bindings (relative to workspace, default 'bindings/winrt'). Persisted to winapp.yaml's jsBindings.output field. */ - output?: string; - /** Do not prompt. When jsBindings: already exists in winapp.yaml, preserve it and exit 0 (idempotent). Use --force instead if you want the existing block patched non-interactively. */ - useDefaults?: boolean; -} - -/** - * Add a jsBindings: block to winapp.yaml and run codegen. Requires winapp.yaml (run 'winapp init' first). Never modifies the packages: section or installs SDK packages — codegen runs against the workspace's already-restored packages. Refuses to clobber an existing jsBindings: block unless --force is passed. Only available when invoked via the \@microsoft/winappcli npm package (npx winapp node jsbindings add). - */ -export async function nodeJsbindingsAdd(options: NodeJsbindingsAddOptions = {}): Promise { - const args: string[] = ['node', 'jsbindings', 'add']; - if (options.baseDirectory) args.push(options.baseDirectory); - if (options.ai) args.push('--ai'); - if (options.configDir) args.push('--config-dir', options.configDir); - if (options.force) args.push('--force'); - if (options.output) args.push('--output', options.output); - if (options.useDefaults) args.push('--use-defaults'); - return execCommand(args, options); -} - -// --------------------------------------------------------------------------- -// node jsbindings generate -// --------------------------------------------------------------------------- - -export interface NodeJsbindingsGenerateOptions extends CommonOptions { - /** Base/root directory for the winapp workspace (default: current directory) */ - baseDirectory?: string; - /** Directory containing winapp.yaml (default: base-directory) */ - configDir?: string; -} - -/** - * Re-run dynwinrt-codegen against the existing jsBindings: block in winapp.yaml. Does NOT modify the yaml — for that, use 'node jsbindings add'. Errors if no jsBindings: block is declared. Only available when invoked via the \@microsoft/winappcli npm package (npx winapp node jsbindings generate). - */ -export async function nodeJsbindingsGenerate(options: NodeJsbindingsGenerateOptions = {}): Promise { - const args: string[] = ['node', 'jsbindings', 'generate']; - if (options.baseDirectory) args.push(options.baseDirectory); - if (options.configDir) args.push('--config-dir', options.configDir); - return execCommand(args, options); -} - // --------------------------------------------------------------------------- // package // --------------------------------------------------------------------------- From 5c0ed9c46ac0e0081c4f32f09bb33674947638cb Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Wed, 20 May 2026 15:21:33 +0800 Subject: [PATCH 09/27] fix ci --- .../WinApp.Cli.Tests/InitCommandTests.cs | 64 +++++++++++++++++-- .../Services/WorkspaceSetupService.Init.cs | 11 ++-- .../Services/WorkspaceSetupService.Prompts.cs | 17 ++++- 3 files changed, 79 insertions(+), 13 deletions(-) diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InitCommandTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InitCommandTests.cs index c36d47ef..37b520f1 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InitCommandTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InitCommandTests.cs @@ -202,11 +202,12 @@ public async Task InitCommand_NativeCallerWithUseDefaults_OmitsJsBindingsBlock() } [TestMethod] - public async Task InitCommand_NpmCallerOnDotNetProject_RejectedWithActionableError() + public async Task InitCommand_NpmCallerOnDotNetProject_SilentlyFallsBackToCppOnly() { - // .NET projects can't host JS bindings; the npm-caller prompt path - // tries to enable them via --use-defaults → Both, so the .NET guard - // must surface a clear error rather than silently producing junk yaml. + // .NET projects can't host JS bindings; rather than asking a question + // with only one valid answer (and tripping the .NET guard when + // --use-defaults → Both), the prompt path silently downgrades to + // CppOnly so dotnet sample tests / CI succeed without intervention. Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); var csprojPath = Path.Combine(_tempDirectory.FullName, "Sample.csproj"); await File.WriteAllTextAsync(csprojPath, @@ -218,11 +219,62 @@ await File.WriteAllTextAsync(csprojPath, + "\n"); var initCommand = GetRequiredService(); - var args = new[] { _tempDirectory.FullName, "--use-defaults" }; + var args = new[] { _tempDirectory.FullName, "--use-defaults", "--config-only" }; + var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); + + Assert.AreEqual(0, exitCode, + ".NET project + npm caller + --use-defaults must succeed (silently downgraded to CppOnly)."); + var combined = ConsoleStdErr.ToString() + ConsoleStdOut.ToString() + TestAnsiConsole.Output; + Assert.IsFalse( + combined.Contains("JS/TS bindings are not supported on .NET", StringComparison.OrdinalIgnoreCase), + $".NET projects must not surface the JS-bindings rejection error — the prompt silently picks CppOnly. Combined output: {combined}"); + // winapp.yaml may or may not be written depending on the SDK install + // mode chosen for .NET projects (which is typically None — .NET pulls + // SDK via NuGet). The key invariant is that, if one is written, it + // must NOT contain a jsBindings block. + var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); + if (File.Exists(configPath)) + { + var configContent = await File.ReadAllTextAsync(configPath); + Assert.IsFalse(configContent.Contains("jsBindings:"), + ".NET project must not gain a jsBindings block — the prompt path silently picks CppOnly."); + } + } + + [TestMethod] + public async Task InitCommand_NpmCallerOnDotNetProjectWithHandEditedJsBindings_RejectedWithActionableError() + { + // Defense-in-depth: if a user manually adds a jsBindings: block to a + // .NET project's winapp.yaml, the .NET guard must still fire with an + // actionable message rather than letting codegen produce junk. + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); + var csprojPath = Path.Combine(_tempDirectory.FullName, "Sample.csproj"); + await File.WriteAllTextAsync(csprojPath, + "\n" + + " \n" + + " Exe\n" + + " net10.0-windows10.0.26100.0\n" + + " \n" + + "\n"); + var existing = """ + packages: + - name: Microsoft.WindowsAppSDK + version: 1.8.39 + - name: Microsoft.Windows.SDK.BuildTools + version: 10.0.26100.5040 + jsBindings: + output: bindings/winrt + lang: js + """; + var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); + await File.WriteAllTextAsync(configPath, existing); + + var initCommand = GetRequiredService(); + var args = new[] { _tempDirectory.FullName, "--config-only", "--use-defaults" }; var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); Assert.AreEqual(1, exitCode, - "JS bindings on a .NET project must exit 1 (codegen target is Node/native, not .NET)."); + "Hand-edited jsBindings: on a .NET project must be rejected by the .NET guard."); var combined = ConsoleStdErr.ToString() + ConsoleStdOut.ToString() + TestAnsiConsole.Output; Assert.IsTrue( combined.Contains(".NET", StringComparison.OrdinalIgnoreCase) diff --git a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Init.cs b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Init.cs index aee5021a..6764e515 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Init.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Init.cs @@ -112,20 +112,21 @@ internal partial class WorkspaceSetupService // and options.SkipCppProjections so the rest of the flow knows what // to generate. No-op when not running via the npm shim, or when an // existing yaml already declares jsBindings:. - var bindingsKind = await AskBindingsKindAsync(options, config, cancellationToken); + var bindingsKind = await AskBindingsKindAsync(options, config, isDotNetProject, cancellationToken); options.AddJsBindings = bindingsKind is BindingsKind.JsOnly or BindingsKind.Both; options.SkipCppProjections = bindingsKind == BindingsKind.JsOnly; // JS/TS bindings target Node/Electron hosts via dynwinrt; .NET projects // already have first-class WinRT projections through CsWinRT and the - // codegen does not produce a .NET-consumable surface. Reject early - // rather than silently writing a jsBindings: block that would fail - // at codegen time. + // codegen does not produce a .NET-consumable surface. AskBindingsKindAsync + // already silently downgrades .NET projects to CppOnly, so this guard only + // catches the case where an existing yaml has a hand-edited `jsBindings:` + // block on a .NET project. if (options.AddJsBindings && isDotNetProject) { logger.LogError( "{UISymbol} JS/TS bindings are not supported on .NET projects — the codegen targets Node/Electron via dynwinrt, and .NET projects already get WinRT via CsWinRT. " + - "Re-run from a non-.NET project, or omit JS bindings at the prompt.", + "Remove the `jsBindings:` block from winapp.yaml, or re-run from a non-.NET project.", UiSymbols.Error); return (1, config, hadExistingConfig, shouldGenerateManifest, manifestGenerationInfo, shouldEnableDeveloperMode, recommendedTfm); } diff --git a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Prompts.cs b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Prompts.cs index 26d08a99..18e0dcb8 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Prompts.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Prompts.cs @@ -250,7 +250,9 @@ private enum BindingsKind // workspace: C++ projections, JS/TS bindings, or both. Defaults to Both // under --use-defaults. Returns CppOnly (the historical default) for // non-npm callers so winget / standalone-CLI users see no behavior change. - private async Task AskBindingsKindAsync(WorkspaceSetupOptions options, WinappConfig? existingConfig, CancellationToken cancellationToken) + // .NET projects also silently get CppOnly — they can't consume dynwinrt + // bindings anyway, so asking would only offer one valid answer. + private async Task AskBindingsKindAsync(WorkspaceSetupOptions options, WinappConfig? existingConfig, bool isDotNetProject, CancellationToken cancellationToken) { // Restore (winapp restore) never re-prompts: it respects whatever the // existing yaml already declares. @@ -268,12 +270,23 @@ private async Task AskBindingsKindAsync(WorkspaceSetupOptions opti // Existing yaml that already declares jsBindings: — don't change the // user's earlier choice. Map it back to a kind so callers can still - // gate on AddJsBindings / SkipCppProjections. + // gate on AddJsBindings / SkipCppProjections. The .NET guard in + // SetupWorkspaceAsync will reject this combination with an actionable + // message; don't pre-empt it here. if (existingConfig?.JsBindings is not null) { return BindingsKindFromConfig(existingConfig); } + // .NET projects can't consume dynwinrt bindings — skip the prompt + // entirely rather than asking a question with only one valid answer + // (and rather than tripping the .NET guard when --use-defaults sets + // the default to Both). + if (isDotNetProject) + { + return BindingsKind.CppOnly; + } + // Non-interactive: default to Both so `npx winapp init --use-defaults` // (sample tests, CI) wires up everything the npm wrapper enables. if (options.UseDefaults) From fad88fb5ff0750557003f6596e2855bdc315dce9 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Wed, 20 May 2026 15:52:42 +0800 Subject: [PATCH 10/27] fix all tests --- .../WinApp.Cli.Tests/InitCommandTests.cs | 55 +++++++++++++++++-- .../Services/WorkspaceSetupService.Init.cs | 9 ++- .../Services/WorkspaceSetupService.Prompts.cs | 9 +++ 3 files changed, 65 insertions(+), 8 deletions(-) diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InitCommandTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InitCommandTests.cs index 37b520f1..2281bf6b 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InitCommandTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InitCommandTests.cs @@ -283,18 +283,63 @@ await File.WriteAllTextAsync(csprojPath, } [TestMethod] - public async Task InitCommand_NpmCallerWithSetupSdksNone_RejectsBecauseBindingsNeedSdks() + public async Task InitCommand_NpmCallerWithSetupSdksNone_SilentlyFallsBackToCppOnly() { - // npm-caller + --use-defaults requests Both, which needs SDK packages - // for the winmd source. --setup-sdks none conflicts → exit 1. + // JS bindings need SDK packages for codegen's winmd source. Rather + // than tripping the SDK-None guard when --use-defaults → Both, the + // prompt path silently picks CppOnly for `--setup-sdks none` so + // samples like rust-app (which don't need winapp to install SDKs) + // succeed without intervention. Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); var initCommand = GetRequiredService(); - var args = new[] { _tempDirectory.FullName, "--use-defaults", "--setup-sdks", "none" }; + var args = new[] { _tempDirectory.FullName, "--use-defaults", "--setup-sdks", "none", "--config-only" }; + var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); + + Assert.AreEqual(0, exitCode, + "--setup-sdks none + npm caller must succeed (silently downgraded to CppOnly)."); + var combined = ConsoleStdErr.ToString() + ConsoleStdOut.ToString() + TestAnsiConsole.Output; + Assert.IsFalse( + combined.Contains("JS/TS bindings need SDK packages", StringComparison.OrdinalIgnoreCase), + $"--setup-sdks none must not surface the SDK-None rejection error — the prompt silently picks CppOnly. Combined output: {combined}"); + // winapp.yaml may or may not be written when SdkInstallMode==None; + // the key invariant is that, if one is written, it must NOT contain + // a jsBindings block. + var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); + if (File.Exists(configPath)) + { + var configContent = await File.ReadAllTextAsync(configPath); + Assert.IsFalse(configContent.Contains("jsBindings:"), + "--setup-sdks none must not gain a jsBindings block — the prompt path silently picks CppOnly."); + } + } + + [TestMethod] + public async Task InitCommand_NpmCallerWithHandEditedJsBindingsAndSetupSdksNone_RejectedWithActionableError() + { + // Defense-in-depth: if a user manually adds a jsBindings: block to + // winapp.yaml AND runs init with --setup-sdks none, the SDK-None + // guard must still fire — codegen has no winmd source to consume. + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); + var existing = """ + packages: + - name: Microsoft.WindowsAppSDK + version: 1.8.39 + - name: Microsoft.Windows.SDK.BuildTools + version: 10.0.26100.5040 + jsBindings: + output: bindings/winrt + lang: js + """; + var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); + await File.WriteAllTextAsync(configPath, existing); + + var initCommand = GetRequiredService(); + var args = new[] { _tempDirectory.FullName, "--config-only", "--use-defaults", "--setup-sdks", "none" }; var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); Assert.AreEqual(1, exitCode, - "--setup-sdks none + JS bindings (via npm caller default Both) must exit 1."); + "Hand-edited jsBindings: + --setup-sdks none must be rejected by the SDK-None guard."); var combined = ConsoleStdErr.ToString() + ConsoleStdOut.ToString() + TestAnsiConsole.Output; Assert.IsTrue( combined.Contains("none", StringComparison.OrdinalIgnoreCase) diff --git a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Init.cs b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Init.cs index 6764e515..a86b87f2 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Init.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Init.cs @@ -131,13 +131,16 @@ internal partial class WorkspaceSetupService return (1, config, hadExistingConfig, shouldGenerateManifest, manifestGenerationInfo, shouldEnableDeveloperMode, recommendedTfm); } - // Re-check after AskSdkInstallModeAsync: the interactive prompt - // can leave SdkInstallMode=None, which breaks JS bindings. + // Re-check after AskSdkInstallModeAsync: if a hand-edited yaml has + // jsBindings: but the user picked --setup-sdks none, codegen has no + // winmd source. AskBindingsKindAsync already silently downgrades the + // SDK-None case for fresh init, so this guard only catches the + // hand-edited-yaml + SDK-None conflict. if (options.AddJsBindings && options.SdkInstallMode == SdkInstallMode.None) { logger.LogError( "{UISymbol} JS/TS bindings need SDK packages, but the SDK install mode was set to 'none'. " + - "Re-run and pick a non-'none' SDK mode (stable / preview / experimental), or pick 'C++ only' at the bindings prompt.", + "Remove the `jsBindings:` block from winapp.yaml, or re-run with a non-'none' SDK mode (stable / preview / experimental).", UiSymbols.Error); return (1, config, hadExistingConfig, shouldGenerateManifest, manifestGenerationInfo, shouldEnableDeveloperMode, recommendedTfm); } diff --git a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Prompts.cs b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Prompts.cs index 18e0dcb8..a072a45d 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Prompts.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Prompts.cs @@ -287,6 +287,15 @@ private async Task AskBindingsKindAsync(WorkspaceSetupOptions opti return BindingsKind.CppOnly; } + // JS bindings need SDK packages for the winmd source. If the user + // (or sample tests like rust-app) picked `--setup-sdks none`, there's + // nothing for codegen to consume. Silently downgrade rather than + // tripping the SDK-None guard with --use-defaults → Both. + if (options.SdkInstallMode == SdkInstallMode.None) + { + return BindingsKind.CppOnly; + } + // Non-interactive: default to Both so `npx winapp init --use-defaults` // (sample tests, CI) wires up everything the npm wrapper enables. if (options.UseDefaults) From ea02855804c04f25e300fb6e96d174a803ebed1c Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 21 May 2026 15:53:53 +0800 Subject: [PATCH 11/27] remove code from native --- .github/plugin/agents/winapp.agent.md | 16 +- .../skills/winapp-cli/frameworks/SKILL.md | 6 +- .../plugin/skills/winapp-cli/setup/SKILL.md | 21 +- docs/cli-schema.json | 2 +- .../fragments/skills/winapp-cli/frameworks.md | 6 +- docs/fragments/skills/winapp-cli/setup.md | 19 +- docs/guides/electron/index.md | 2 +- docs/guides/electron/jsbindings.md | 61 +- docs/js-bindings.md | 400 +++++---- docs/npm-usage.md | 2 +- docs/usage.md | 28 +- samples/electron/package.json | 3 + samples/electron/test.Tests.ps1 | 39 +- .../WinApp.Cli.Tests/BaseCommandTests.cs | 9 - .../ConfigServiceJsBindingsTests.cs | 811 ------------------ .../DynWinrtCodegenArgvTests.cs | 349 -------- .../DynWinrtCodegenInvocationTests.cs | 432 ---------- .../DynWinrtCodegenOutputSafetyTests.cs | 250 ------ .../DynWinrtCodegenStagingTests.cs | 185 ---- .../WinApp.Cli.Tests/InitCommandTests.cs | 294 ------- .../JsBindingsPresetsTests.cs | 319 ------- .../NpmWrapperVersionProviderTests.cs | 168 ---- .../PackageManagerDetectorTests.cs | 149 ---- .../WinApp.Cli.Tests/PathSafetyTests.cs | 6 +- .../TestDoubles/FakeDynWinrtCodegenService.cs | 73 -- .../FakeJsBindingsWorkspaceService.cs | 38 - .../UserPackageJsonServiceTests.cs | 327 ------- .../WinappConfigDocumentTests.cs | 497 ----------- .../WinmdsLockfileServiceTests.cs | 163 +--- .../WorkspaceSetupServiceTests.cs | 194 ----- .../WinApp.Cli/Commands/InitCommand.cs | 2 +- .../Helpers/HostBuilderExtensions.cs | 5 - .../WinApp.Cli/Helpers/PathSafety.cs | 6 +- .../WinApp.Cli/Models/JsBindingsConfig.cs | 43 - .../WinApp.Cli/Models/WinappConfig.cs | 9 - .../WinApp.Cli/Models/WinmdsLockfile.cs | 20 +- .../WinApp.Cli/Services/ConfigService.cs | 44 - .../Services/DynWinrtCodegenService.cs | 730 ---------------- .../WinApp.Cli/Services/IConfigService.cs | 3 - .../Services/IDynWinrtCodegenService.cs | 23 - .../Services/IJsBindingsWorkspaceService.cs | 48 -- .../Services/INpmWrapperVersionProvider.cs | 12 - .../Services/IPackageManagerDetector.cs | 16 - .../Services/IUserPackageJsonService.cs | 24 - .../WinApp.Cli/Services/JsBindingsPresets.cs | 184 ---- ...dingsWorkspaceService.RuntimeDependency.cs | 74 -- ...BindingsWorkspaceService.WinmdDiscovery.cs | 219 ----- .../Services/JsBindingsWorkspaceService.cs | 377 -------- .../Services/NpmWrapperVersionProvider.cs | 122 --- .../Services/PackageManagerDetector.cs | 95 -- .../Services/UserPackageJsonService.cs | 164 ---- .../Services/WinappConfigDocument.cs | 439 +--------- .../Services/WinmdsLockfileService.cs | 32 +- .../Services/WorkspaceSetupService.Init.cs | 76 +- .../Services/WorkspaceSetupService.Options.cs | 9 - .../Services/WorkspaceSetupService.Prompts.cs | 98 --- .../Services/WorkspaceSetupService.cs | 197 +---- src/winapp-npm/README.md | 2 +- src/winapp-npm/src/cli.ts | 246 +++++- .../src/jsbindings/additional-winmds.ts | 122 +++ .../src/jsbindings/codegen-runner.ts | 605 +++++++++++++ src/winapp-npm/src/jsbindings/init-prompt.ts | 233 +++++ .../src/jsbindings/lockfile-reader.ts | 128 +++ src/winapp-npm/src/jsbindings/orchestrator.ts | 208 +++++ .../src/jsbindings/package-json-config.ts | 275 ++++++ .../jsbindings/package-manager-detector.ts | 93 ++ src/winapp-npm/src/jsbindings/path-safety.ts | 109 +++ .../src/jsbindings/runtime-dep-injector.ts | 262 ++++++ src/winapp-npm/src/jsbindings/spinner.ts | 80 ++ src/winapp-npm/src/jsbindings/winmd-policy.ts | 139 +++ src/winapp-npm/src/winapp-commands.ts | 2 +- 71 files changed, 2951 insertions(+), 7493 deletions(-) delete mode 100644 src/winapp-CLI/WinApp.Cli.Tests/ConfigServiceJsBindingsTests.cs delete mode 100644 src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenArgvTests.cs delete mode 100644 src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenInvocationTests.cs delete mode 100644 src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenOutputSafetyTests.cs delete mode 100644 src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenStagingTests.cs delete mode 100644 src/winapp-CLI/WinApp.Cli.Tests/JsBindingsPresetsTests.cs delete mode 100644 src/winapp-CLI/WinApp.Cli.Tests/NpmWrapperVersionProviderTests.cs delete mode 100644 src/winapp-CLI/WinApp.Cli.Tests/PackageManagerDetectorTests.cs delete mode 100644 src/winapp-CLI/WinApp.Cli.Tests/TestDoubles/FakeDynWinrtCodegenService.cs delete mode 100644 src/winapp-CLI/WinApp.Cli.Tests/TestDoubles/FakeJsBindingsWorkspaceService.cs delete mode 100644 src/winapp-CLI/WinApp.Cli.Tests/UserPackageJsonServiceTests.cs delete mode 100644 src/winapp-CLI/WinApp.Cli.Tests/WinappConfigDocumentTests.cs delete mode 100644 src/winapp-CLI/WinApp.Cli/Models/JsBindingsConfig.cs delete mode 100644 src/winapp-CLI/WinApp.Cli/Services/DynWinrtCodegenService.cs delete mode 100644 src/winapp-CLI/WinApp.Cli/Services/IDynWinrtCodegenService.cs delete mode 100644 src/winapp-CLI/WinApp.Cli/Services/IJsBindingsWorkspaceService.cs delete mode 100644 src/winapp-CLI/WinApp.Cli/Services/INpmWrapperVersionProvider.cs delete mode 100644 src/winapp-CLI/WinApp.Cli/Services/IPackageManagerDetector.cs delete mode 100644 src/winapp-CLI/WinApp.Cli/Services/IUserPackageJsonService.cs delete mode 100644 src/winapp-CLI/WinApp.Cli/Services/JsBindingsPresets.cs delete mode 100644 src/winapp-CLI/WinApp.Cli/Services/JsBindingsWorkspaceService.RuntimeDependency.cs delete mode 100644 src/winapp-CLI/WinApp.Cli/Services/JsBindingsWorkspaceService.WinmdDiscovery.cs delete mode 100644 src/winapp-CLI/WinApp.Cli/Services/JsBindingsWorkspaceService.cs delete mode 100644 src/winapp-CLI/WinApp.Cli/Services/NpmWrapperVersionProvider.cs delete mode 100644 src/winapp-CLI/WinApp.Cli/Services/PackageManagerDetector.cs delete mode 100644 src/winapp-CLI/WinApp.Cli/Services/UserPackageJsonService.cs create mode 100644 src/winapp-npm/src/jsbindings/additional-winmds.ts create mode 100644 src/winapp-npm/src/jsbindings/codegen-runner.ts create mode 100644 src/winapp-npm/src/jsbindings/init-prompt.ts create mode 100644 src/winapp-npm/src/jsbindings/lockfile-reader.ts create mode 100644 src/winapp-npm/src/jsbindings/orchestrator.ts create mode 100644 src/winapp-npm/src/jsbindings/package-json-config.ts create mode 100644 src/winapp-npm/src/jsbindings/package-manager-detector.ts create mode 100644 src/winapp-npm/src/jsbindings/path-safety.ts create mode 100644 src/winapp-npm/src/jsbindings/runtime-dep-injector.ts create mode 100644 src/winapp-npm/src/jsbindings/spinner.ts create mode 100644 src/winapp-npm/src/jsbindings/winmd-policy.ts diff --git a/.github/plugin/agents/winapp.agent.md b/.github/plugin/agents/winapp.agent.md index 21512bcd..4b91114c 100644 --- a/.github/plugin/agents/winapp.agent.md +++ b/.github/plugin/agents/winapp.agent.md @@ -29,7 +29,7 @@ Does the project already have an appxmanifest.xml? ├─ Has winapp.yaml, cloned/pulled but .winapp/ folder is missing? │ └─ winapp restore ├─ Want to add typed JS/TypeScript WinRT bindings to an existing workspace? - │ └─ Edit winapp.yaml to add `jsBindings: {}`, then run `npx winapp restore` + │ └─ Edit package.json to add `"winapp": { "jsBindings": {} }`, then run `npx winapp restore` ├─ Want to check for newer SDK versions? │ └─ winapp update ├─ Only need an appxmanifest.xml (no SDKs, no cert, no config)? @@ -92,8 +92,8 @@ Want to inspect or interact with a running app's UI? **Creates:** `winapp.yaml`, `appxmanifest.xml`, `Assets/` folder, `.winapp/` (if SDKs installed) ### `winapp restore [base-directory]` -**Purpose:** Reinstall SDK packages from existing config without changing versions. Also re-runs JS/TS binding codegen when `winapp.yaml` declares a `jsBindings:` block. -**When to use:** After cloning a repo that has `winapp.yaml`, when the `.winapp/` folder is missing/corrupted, or after editing the `jsBindings:` block by hand. +**Purpose:** Reinstall SDK packages from existing config without changing versions. Also re-runs JS/TS binding codegen when `package.json` declares a `"winapp.jsBindings"` namespace. +**When to use:** After cloning a repo that has `winapp.yaml`, when the `.winapp/` folder is missing/corrupted, or after editing the `winapp.jsBindings` namespace in `package.json` by hand. **Requires:** `winapp.yaml` ### `winapp update` @@ -106,10 +106,10 @@ Want to inspect or interact with a running app's UI? **Purpose:** Generate typed JS/TS WinRT bindings (via `@microsoft/dynwinrt-codegen`) so Node / Electron apps can call WinRT APIs directly without a native build step. **When to use:** Inside a Node / Electron project after `npx winapp init`. **How to enable:** -- **Fresh init via npm shim** (`npx winapp init`) shows an interactive prompt offering **C++ projections**, **JS/TS bindings**, or **Both** (default with `--use-defaults`: Both). Pick JS/Both to wire `jsBindings: {}` (covering the full Windows App SDK) into `winapp.yaml`. -- **Existing workspace:** edit `winapp.yaml` to add a `jsBindings:` block (e.g. `jsBindings: {}` for full SDK; add `cppProjections: false` at the top level to skip cppwinrt). Then run `npx winapp restore` — it re-runs codegen against the existing yaml without modifying it. -- **Re-run codegen** after editing `jsBindings.packages` / `extraTypes` / `additionalWinmds` by hand: `npx winapp restore`. -**Notes:** npm-only — the interactive prompt only fires when invoked through `npx winapp …`. Standalone winget / installer builds do not generate JS bindings. Codegen always auto-injects `@microsoft/dynwinrt` as a production dep into `package.json`. See [JS bindings docs](https://github.com/microsoft/winappcli/blob/main/docs/js-bindings.md) for the full `jsBindings:` schema. +- **Fresh init via npm shim** (`npx winapp init`) shows an interactive yes/no prompt — `Add JS/TypeScript bindings to this project? [Y/n]:`. Press Enter (or pass `--use-defaults`) to opt in; the wrapper writes a default `"winapp": { "jsBindings": {} }` namespace (covering the full Windows App SDK) into `package.json`. C++ projections are always generated regardless of the answer. +- **Existing workspace:** edit `package.json` to add `"winapp": { "jsBindings": {} }` (the empty object opts in with full-SDK defaults). Then run `npx winapp restore` — it re-runs codegen against the existing config without modifying it. +- **Re-run codegen** after editing `winapp.jsBindings.packages` / `extraTypes` / `additionalWinmds` by hand: `npx winapp restore`. +**Notes:** npm-only — the interactive prompt only fires when invoked through `npx winapp …`. Standalone winget / installer builds do not generate JS bindings. Codegen always auto-injects `@microsoft/dynwinrt` as a production dep into `package.json`. See [JS bindings docs](https://github.com/microsoft/winappcli/blob/main/docs/js-bindings.md) for the full `winapp.jsBindings` schema. ### `winapp package ` (alias: `winapp pack`) **Purpose:** Create an MSIX installer from a built app. @@ -229,7 +229,7 @@ Want to inspect or interact with a running app's UI? ### Electron - **Setup:** `winapp init --use-defaults` → choose your Windows API access path: - - **JS bindings (easiest, npm-only):** at the `npx winapp init` prompt pick "JS/TS bindings" or "Both" (or pass `--use-defaults` to auto-pick Both). On an existing workspace, add `jsBindings: {}` to `winapp.yaml` and run `npx winapp restore`. Generates typed `bindings/winrt/*.{js,d.ts}` for the full Windows App SDK surface, callable directly from your main/renderer process via dynwinrt. No native build step. + - **JS bindings (easiest, npm-only):** at the `npx winapp init` prompt answer **Y** (the default — or pass `--use-defaults`). On an existing workspace, add `"winapp": { "jsBindings": {} }` to `package.json` and run `npx winapp restore`. Generates typed `bindings/*.{js,d.ts}` for the full Windows App SDK surface, callable directly from your main/renderer process via dynwinrt. No native build step. - **Native addons:** `winapp node create-addon --template cs` (or `--template cpp`) for C#/C++ addons when you need full WinRT access or stateful native services. - Then: `winapp node add-electron-debug-identity` to enable identity-required APIs. - **Package:** Build with your packager (e.g., Electron Forge), then `winapp package --cert .\devcert.pfx` diff --git a/.github/plugin/skills/winapp-cli/frameworks/SKILL.md b/.github/plugin/skills/winapp-cli/frameworks/SKILL.md index 3c1caf64..122d2b06 100644 --- a/.github/plugin/skills/winapp-cli/frameworks/SKILL.md +++ b/.github/plugin/skills/winapp-cli/frameworks/SKILL.md @@ -30,7 +30,7 @@ Use the **npm package** (`@Microsoft/WinAppCli`), **not** the standalone CLI. Th - The native winapp CLI binary bundled inside `node_modules` - A Node.js SDK with helpers for creating native C#/C++ addons - Electron-specific commands under `npx winapp node` -- An interactive bindings prompt during `npx winapp init` that offers **C++ projections**, **JS/TS bindings** (typed JS/TypeScript WinRT wrappers via dynwinrt, no native build required), or **Both** +- An interactive yes/no bindings prompt during `npx winapp init` — `Add JS/TypeScript bindings to this project? [Y/n]:` — that opts your project into typed JS/TypeScript WinRT wrappers via dynwinrt (no native build required) Quick start: ```powershell @@ -41,7 +41,7 @@ npx winapp node create-addon --template cs # create a C# native addon (fo npx winapp node add-electron-debug-identity # register identity for debugging ``` -JS/TS bindings (the `jsBindings:` block in `winapp.yaml`) are **npm-only** — they require invocation via the `@microsoft/winappcli` npm package because they pull in `@microsoft/dynwinrt-codegen` as a transitive dep. The bindings prompt does not appear when running the standalone winget CLI. +JS/TS bindings (the `"winapp": { "jsBindings": {...} }` namespace in `package.json`) are **npm-only** — they require invocation via the `@microsoft/winappcli` npm package because they pull in `@microsoft/dynwinrt-codegen` as a transitive dep. The bindings prompt does not appear when running the standalone winget CLI. #### Choosing between jsBindings and a native addon @@ -60,7 +60,7 @@ The decision is almost entirely about the **shape of the API**, not preference. It's normal to mix both in one app: jsBindings for the non-UI WinRT surface, a small C# or C++ addon for the one or two Win32 / non-WinRT calls that don't fit. Additional Electron guides: -- [JS bindings reference](https://github.com/microsoft/WinAppCli/blob/main/docs/js-bindings.md) — full `jsBindings:` yaml schema, per-package classification, lockfile +- [JS bindings reference](https://github.com/microsoft/WinAppCli/blob/main/docs/js-bindings.md) — full `winapp.jsBindings` JSON schema, per-package classification, lockfile - [Packaging guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/packaging.md) - [C++ notification addon guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/cpp-notification-addon.md) - [WinML addon guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/winml-addon.md) diff --git a/.github/plugin/skills/winapp-cli/setup/SKILL.md b/.github/plugin/skills/winapp-cli/setup/SKILL.md index 8f4f6ffe..4137d486 100644 --- a/.github/plugin/skills/winapp-cli/setup/SKILL.md +++ b/.github/plugin/skills/winapp-cli/setup/SKILL.md @@ -55,24 +55,25 @@ winapp init --use-defaults --setup-sdks preview ### Add JS/TS bindings for Node / Electron apps (npm only) When invoked via the `@microsoft/winappcli` npm package (i.e. `npx winapp init` -inside a Node / Electron project), `init` adds an interactive **bindings prompt** -that asks whether to generate **C++ projections**, **JS/TS bindings**, or **Both**. -Picking JS or Both wires a default `jsBindings:` block into `winapp.yaml` (covering -the full Windows App SDK) and runs codegen as part of init: +inside a Node / Electron project), `init` adds an interactive yes/no **bindings +prompt** — `Add JS/TypeScript bindings to this project? [Y/n]:`. Answering Yes +(the default) wires a default `"winapp": { "jsBindings": {} }` namespace into +`package.json` (covering the full Windows App SDK) and runs codegen as part of +init. C++ projections are always generated regardless of the answer. ```powershell -# Interactive — prompted to pick C++ / JS / Both. +# Interactive — prompted with the yes/no question. npx winapp init -# Non-interactive — auto-picks "Both" (C++ projections + JS/TS bindings). +# Non-interactive — auto-answers Yes (opts in to JS bindings). npx winapp init --use-defaults -# After editing winapp.yaml jsBindings: by hand (or pulling a teammate's -# winapp.yaml), regenerate bindings without re-prompting: +# After editing winapp.jsBindings in package.json by hand (or pulling a +# teammate's package.json), regenerate bindings without re-prompting: npx winapp restore ``` -Generated files land under `bindings/winrt/` and `@microsoft/dynwinrt` is +Generated files land under `bindings/` and `@microsoft/dynwinrt` is added to your `package.json` dependencies so production installs include it. After `init`, your project will contain: @@ -181,7 +182,7 @@ For full debugging scenarios and IDE setup, see the [Debugging Guide](https://gi ### `winapp init` -Start here for initializing a Windows app with required setup. Sets up everything needed for Windows app development: creates Package.appxmanifest with default assets, downloads Windows SDK and Windows App SDK packages, and generates projections. When SDK packages are managed (--setup-sdks stable/preview/experimental), also creates winapp.yaml to pin versions for 'restore'/'update'; with --setup-sdks none (e.g., for Rust/Tauri projects that bring their own SDK bindings), no winapp.yaml is created. When invoked via the @microsoft/winappcli npm package (npx winapp init), additionally asks whether to generate C++ projections, JS/TS bindings, or both. Interactive by default (use --use-defaults to skip prompts). Use 'restore' instead if you cloned a repo that already has winapp.yaml. Use 'manifest generate' if you only need a manifest, or 'cert generate' if you need a development certificate for code signing. +Start here for initializing a Windows app with required setup. Sets up everything needed for Windows app development: creates Package.appxmanifest with default assets, downloads Windows SDK and Windows App SDK packages, and generates projections. When SDK packages are managed (--setup-sdks stable/preview/experimental), also creates winapp.yaml to pin versions for 'restore'/'update'; with --setup-sdks none (e.g., for Rust/Tauri projects that bring their own SDK bindings), no winapp.yaml is created. Interactive by default (use --use-defaults to skip prompts). Use 'restore' instead if you cloned a repo that already has winapp.yaml. Use 'manifest generate' if you only need a manifest, or 'cert generate' if you need a development certificate for code signing. #### Arguments diff --git a/docs/cli-schema.json b/docs/cli-schema.json index 1cfbb2a0..a0d242ba 100644 --- a/docs/cli-schema.json +++ b/docs/cli-schema.json @@ -626,7 +626,7 @@ } }, "init": { - "description": "Start here for initializing a Windows app with required setup. Sets up everything needed for Windows app development: creates Package.appxmanifest with default assets, downloads Windows SDK and Windows App SDK packages, and generates projections. When SDK packages are managed (--setup-sdks stable/preview/experimental), also creates winapp.yaml to pin versions for 'restore'/'update'; with --setup-sdks none (e.g., for Rust/Tauri projects that bring their own SDK bindings), no winapp.yaml is created. When invoked via the @microsoft/winappcli npm package (npx winapp init), additionally asks whether to generate C++ projections, JS/TS bindings, or both. Interactive by default (use --use-defaults to skip prompts). Use 'restore' instead if you cloned a repo that already has winapp.yaml. Use 'manifest generate' if you only need a manifest, or 'cert generate' if you need a development certificate for code signing.", + "description": "Start here for initializing a Windows app with required setup. Sets up everything needed for Windows app development: creates Package.appxmanifest with default assets, downloads Windows SDK and Windows App SDK packages, and generates projections. When SDK packages are managed (--setup-sdks stable/preview/experimental), also creates winapp.yaml to pin versions for 'restore'/'update'; with --setup-sdks none (e.g., for Rust/Tauri projects that bring their own SDK bindings), no winapp.yaml is created. Interactive by default (use --use-defaults to skip prompts). Use 'restore' instead if you cloned a repo that already has winapp.yaml. Use 'manifest generate' if you only need a manifest, or 'cert generate' if you need a development certificate for code signing.", "hidden": false, "arguments": { "base-directory": { diff --git a/docs/fragments/skills/winapp-cli/frameworks.md b/docs/fragments/skills/winapp-cli/frameworks.md index 2abe2d53..3e971df4 100644 --- a/docs/fragments/skills/winapp-cli/frameworks.md +++ b/docs/fragments/skills/winapp-cli/frameworks.md @@ -25,7 +25,7 @@ Use the **npm package** (`@Microsoft/WinAppCli`), **not** the standalone CLI. Th - The native winapp CLI binary bundled inside `node_modules` - A Node.js SDK with helpers for creating native C#/C++ addons - Electron-specific commands under `npx winapp node` -- An interactive bindings prompt during `npx winapp init` that offers **C++ projections**, **JS/TS bindings** (typed JS/TypeScript WinRT wrappers via dynwinrt, no native build required), or **Both** +- An interactive yes/no bindings prompt during `npx winapp init` — `Add JS/TypeScript bindings to this project? [Y/n]:` — that opts your project into typed JS/TypeScript WinRT wrappers via dynwinrt (no native build required) Quick start: ```powershell @@ -36,7 +36,7 @@ npx winapp node create-addon --template cs # create a C# native addon (fo npx winapp node add-electron-debug-identity # register identity for debugging ``` -JS/TS bindings (the `jsBindings:` block in `winapp.yaml`) are **npm-only** — they require invocation via the `@microsoft/winappcli` npm package because they pull in `@microsoft/dynwinrt-codegen` as a transitive dep. The bindings prompt does not appear when running the standalone winget CLI. +JS/TS bindings (the `"winapp": { "jsBindings": {...} }` namespace in `package.json`) are **npm-only** — they require invocation via the `@microsoft/winappcli` npm package because they pull in `@microsoft/dynwinrt-codegen` as a transitive dep. The bindings prompt does not appear when running the standalone winget CLI. #### Choosing between jsBindings and a native addon @@ -55,7 +55,7 @@ The decision is almost entirely about the **shape of the API**, not preference. It's normal to mix both in one app: jsBindings for the non-UI WinRT surface, a small C# or C++ addon for the one or two Win32 / non-WinRT calls that don't fit. Additional Electron guides: -- [JS bindings reference](https://github.com/microsoft/WinAppCli/blob/main/docs/js-bindings.md) — full `jsBindings:` yaml schema, per-package classification, lockfile +- [JS bindings reference](https://github.com/microsoft/WinAppCli/blob/main/docs/js-bindings.md) — full `winapp.jsBindings` JSON schema, per-package classification, lockfile - [Packaging guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/packaging.md) - [C++ notification addon guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/cpp-notification-addon.md) - [WinML addon guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/winml-addon.md) diff --git a/docs/fragments/skills/winapp-cli/setup.md b/docs/fragments/skills/winapp-cli/setup.md index 3f6ecf26..f3b6d5b2 100644 --- a/docs/fragments/skills/winapp-cli/setup.md +++ b/docs/fragments/skills/winapp-cli/setup.md @@ -50,24 +50,25 @@ winapp init --use-defaults --setup-sdks preview ### Add JS/TS bindings for Node / Electron apps (npm only) When invoked via the `@microsoft/winappcli` npm package (i.e. `npx winapp init` -inside a Node / Electron project), `init` adds an interactive **bindings prompt** -that asks whether to generate **C++ projections**, **JS/TS bindings**, or **Both**. -Picking JS or Both wires a default `jsBindings:` block into `winapp.yaml` (covering -the full Windows App SDK) and runs codegen as part of init: +inside a Node / Electron project), `init` adds an interactive yes/no **bindings +prompt** — `Add JS/TypeScript bindings to this project? [Y/n]:`. Answering Yes +(the default) wires a default `"winapp": { "jsBindings": {} }` namespace into +`package.json` (covering the full Windows App SDK) and runs codegen as part of +init. C++ projections are always generated regardless of the answer. ```powershell -# Interactive — prompted to pick C++ / JS / Both. +# Interactive — prompted with the yes/no question. npx winapp init -# Non-interactive — auto-picks "Both" (C++ projections + JS/TS bindings). +# Non-interactive — auto-answers Yes (opts in to JS bindings). npx winapp init --use-defaults -# After editing winapp.yaml jsBindings: by hand (or pulling a teammate's -# winapp.yaml), regenerate bindings without re-prompting: +# After editing winapp.jsBindings in package.json by hand (or pulling a +# teammate's package.json), regenerate bindings without re-prompting: npx winapp restore ``` -Generated files land under `bindings/winrt/` and `@microsoft/dynwinrt` is +Generated files land under `bindings/` and `@microsoft/dynwinrt` is added to your `package.json` dependencies so production installs include it. After `init`, your project will contain: diff --git a/docs/guides/electron/index.md b/docs/guides/electron/index.md index 92c2a0a1..16741897 100644 --- a/docs/guides/electron/index.md +++ b/docs/guides/electron/index.md @@ -40,7 +40,7 @@ Next, choose how to call Windows APIs from your Electron app: #### Option A: [JS/TypeScript bindings via dynwinrt](../../js-bindings.md) ✨ *new* -The simplest path — typed JS/TypeScript wrappers generated from `.winmd` metadata, no native build step required from your Electron project. When you run `npx winapp init`, the interactive bindings prompt offers **C++**, **JS/TS**, or **Both**; pick JS or Both and `bindings/winrt/` is dropped next to your sources. You `import { ChatClient } from './bindings/winrt'` and call WinRT directly. Bindings are typed at compile time but use `dynwinrt`'s libffi runtime to invoke methods at runtime, so no MSBuild / `node-gyp` step is involved. +The simplest path — typed JS/TypeScript wrappers generated from `.winmd` metadata, no native build step required from your Electron project. When you run `npx winapp init`, you'll be asked `Add JS/TypeScript bindings to this project? [Y/n]:` — answer **Y** (or pass `--use-defaults`) and a `bindings/` directory is dropped next to your sources. You `import { ChatClient } from './bindings'` and call WinRT directly. Bindings are typed at compile time but use `dynwinrt`'s libffi runtime to invoke methods at runtime, so no MSBuild / `node-gyp` step is involved. [Add JS bindings →](../../js-bindings.md) diff --git a/docs/guides/electron/jsbindings.md b/docs/guides/electron/jsbindings.md index f7f5c940..611b4ce6 100644 --- a/docs/guides/electron/jsbindings.md +++ b/docs/guides/electron/jsbindings.md @@ -17,19 +17,16 @@ You have two paths depending on whether your Electron app already has a `winapp. ### Path A — Fresh project (init with bindings prompt) -When you run `npx winapp init` for the first time, the CLI shows an interactive **bindings prompt** asking whether to generate **C++ projections**, **JS/TS bindings**, or **Both**: +When you run `npx winapp init` for the first time, the CLI shows an interactive yes/no prompt asking whether to add JS bindings on top of the standard C++ projection workspace: ```bash npx winapp init -# > Bindings to generate: -# C++ projections -# JS/TS bindings -# ❯ Both (default) +# > Add JS/TypeScript bindings to this project? [Y/n]: ``` -Pick **JS/TS bindings** for a pure Node/Electron project (skips cppwinrt headers/libs — saves ~130 MB and ~20 s), or **Both** to keep both surfaces available. `init` then installs the WinAppSDK packages, writes a `winapp.yaml` containing a default `jsBindings:` block (covering the full Windows App SDK), and runs the codegen. +Press **Enter** (default Yes) to opt in. `init` installs the WinAppSDK packages, generates the C++ projections (always), adds a default `"winapp": { "jsBindings": {} }` namespace to `package.json` (covering the full Windows App SDK), and runs the codegen. -For a scripted / CI install, `--use-defaults` auto-picks **Both** without prompting: +For a scripted / CI install, `--use-defaults` auto-opts in without prompting: ```bash npx winapp init --use-defaults @@ -38,22 +35,18 @@ npm install # picks up the @microsoft/dynwinrt runtime de ### Path B — Existing project (layer bindings on) -If `winapp.yaml` already exists and you want to add JS bindings, edit the yaml and add a `jsBindings:` block. The empty form covers the full Windows App SDK: +If `winapp.yaml` already exists and you want to add JS bindings, edit `package.json` to add the `winapp.jsBindings` namespace. The empty form covers the full Windows App SDK: -```yaml -# winapp.yaml -jsBindings: {} -``` - -If you want JS bindings without cppwinrt projections, also set `cppProjections: false` at the top level: - -```yaml -# winapp.yaml -cppProjections: false -jsBindings: {} +```jsonc +// package.json +{ + "winapp": { + "jsBindings": {} + } +} ``` -Then run `restore` — it will pick up the new block, run codegen, and inject the `@microsoft/dynwinrt` runtime dep into `package.json`: +Then run `restore` — it will pick up the new namespace, run codegen, and inject the `@microsoft/dynwinrt` runtime dep into `package.json`: ```bash npx winapp restore @@ -62,10 +55,10 @@ npm install ### What you get -Both paths produce a `bindings/winrt/` directory next to your sources: +Both paths produce a `bindings/` directory next to your sources: ``` -bindings/winrt/ +bindings/ ├── index.js # entry — re-exports every emitted class ├── index.d.ts # TS bundle ├── Microsoft.Windows.Vision.TextRecognizer.js @@ -75,14 +68,14 @@ bindings/winrt/ └── … # one pair of files per emitted class ``` -To put them somewhere else, set `jsBindings.output:` in `winapp.yaml` (e.g. `output: src/generated/winrt`) and re-run `restore`. +To put them somewhere else, set `output` inside `winapp.jsBindings` in `package.json` (e.g. `"output": "src/generated/winrt"`) and re-run `restore`. > [!NOTE] > If you need to slice generation by NuGet package, add your own `.winmd`, or cherry-pick a few classes from a giant vendor SDK, see the recipes in [JS / TypeScript bindings for WinRT](../../js-bindings.md). This guide sticks to the simplest default-scope flow. ## Step 2: Call a WinRT API from your Electron code -Import from the generated `index.js` — you don't need to know which file inside `bindings/winrt/` a class lives in. Here's an OCR (text recognition) flow as it would run in your Electron main process. We use `TextRecognizer` rather than `LanguageModel` because it doesn't require a Limited Access Feature token, so you can run this end-to-end on any Copilot+ PC without applying for access: +Import from the generated `index.js` — you don't need to know which file inside `bindings/` a class lives in. Here's an OCR (text recognition) flow as it would run in your Electron main process. We use `TextRecognizer` rather than `LanguageModel` because it doesn't require a Limited Access Feature token, so you can run this end-to-end on any Copilot+ PC without applying for access: ```js // src/index.js (Electron main) @@ -90,7 +83,7 @@ const path = require('path'); const { TextRecognizer, AIFeatureReadyState, -} = require('./bindings/winrt/index.js'); +} = require('./bindings/index.js'); async function recognizeText(imagePath) { // First-run model download (one time per user) — cheap no-op once cached. @@ -116,7 +109,7 @@ async function recognizeText(imagePath) { // lines.forEach(l => console.log(`(${l.x}, ${l.y}): ${l.text}`)); ``` -For the full text-generation (Phi Silica `LanguageModel`) flow — which also lives in the same `bindings/winrt/` output — see the [Windows AI APIs reference](https://learn.microsoft.com/windows/ai/apis/). That surface requires a [Limited Access Feature token](https://learn.microsoft.com/windows/apps/develop/limited-access-features) before `LanguageModel.createAsync()` will succeed. +For the full text-generation (Phi Silica `LanguageModel`) flow — which also lives in the same `bindings/` output — see the [Windows AI APIs reference](https://learn.microsoft.com/windows/ai/apis/). That surface requires a [Limited Access Feature token](https://learn.microsoft.com/windows/apps/develop/limited-access-features) before `LanguageModel.createAsync()` will succeed. A few conventions to remember: @@ -145,17 +138,17 @@ Now start the app: npm start ``` -The first call to a WinRT method imported from `bindings/winrt/` will load `@microsoft/dynwinrt`, resolve the `.winmd` metadata, and invoke the COM method via libffi — all transparent to your code. +The first call to a WinRT method imported from `bindings/` will load `@microsoft/dynwinrt`, resolve the `.winmd` metadata, and invoke the COM method via libffi — all transparent to your code. ## Step 4 (optional): Regenerate after a metadata change -The generated `bindings/winrt/` files are committed-or-gitignored at your discretion (treat them like `package-lock.json` — generated, but stable enough to commit if you want diff visibility). Regenerate whenever: +The generated `bindings/` files are committed-or-gitignored at your discretion (treat them like `package-lock.json` — generated, but stable enough to commit if you want diff visibility). Regenerate whenever: - You bump a WinAppSDK / WinRT package version in `winapp.yaml` -- You add or remove entries in `jsBindings.packages:` / `additionalWinmds:` / `extraTypes:` +- You add or remove entries in `winapp.jsBindings.packages` / `additionalWinmds` / `extraTypes` (in `package.json`) - The codegen itself is upgraded (`npm update @microsoft/dynwinrt-codegen`) -In all cases, re-run `restore` — it picks up the current `winapp.yaml` (no yaml mutation) and re-runs codegen: +In all cases, re-run `restore` — it picks up the current `winapp.yaml` and `package.json` (neither file is mutated) and re-runs codegen: ```bash npx winapp restore @@ -163,11 +156,11 @@ npx winapp restore ## Troubleshooting -**`Cannot find module './bindings/winrt'`** -The generator hasn't produced output yet. Re-run `npx winapp restore` and verify `bindings/winrt/index.js` exists. +**`Cannot find module './bindings'`** +The generator hasn't produced output yet. Re-run `npx winapp restore` and verify `bindings/index.js` exists. **`MissingMethodException` / `Type not registered`** -A class your code imports is in a `.winmd` that isn't on the codegen's input. Check the `packages:` list (or `additionalWinmds:`) in `winapp.yaml` — empty/omitted `jsBindings.packages` means "all installed packages participate", but if you've curated the list make sure the relevant package is there. +A class your code imports is in a `.winmd` that isn't on the codegen's input. Check the `packages` list (or `additionalWinmds`) inside `winapp.jsBindings` in `package.json` — empty/omitted `packages` means "all installed packages participate", but if you've curated the list make sure the relevant package is there. **`HRESULT 0x8007XXXX` at call time** The metadata was emitted but the OS implementation isn't available — usually a missing OS feature (e.g., a Windows AI API on a non-Copilot+ PC) or missing capability declaration in `Package.appxmanifest`. The exception message preserves the WinRT error string from the COM layer. @@ -177,7 +170,7 @@ Make sure `@microsoft/dynwinrt` is in your runtime `dependencies` (not just `dev ## Next steps -- **Reference** — [JS / TypeScript bindings for WinRT (`jsBindings`)](../../js-bindings.md) for the full `winapp.yaml` schema and advanced recipes (slice by package, cherry-pick types, ship a vendor `.winmd`). +- **Reference** — [JS / TypeScript bindings for WinRT (`winapp.jsBindings`)](../../js-bindings.md) for the full `package.json` schema and advanced recipes (slice by package, cherry-pick types, ship a vendor `.winmd`). - **CLI** — [`npx winapp init` reference](../../usage.md#init) and [`npx winapp restore` reference](../../usage.md#restore). - **Runtime** — [`@microsoft/dynwinrt` on GitHub](https://github.com/microsoft/dynwinrt) for the libffi-based runtime that powers the generated bindings. - **Package & ship** — [Packaging Your App](packaging.md) once you're ready to produce an MSIX for distribution. diff --git a/docs/js-bindings.md b/docs/js-bindings.md index 1496fc01..388edf90 100644 --- a/docs/js-bindings.md +++ b/docs/js-bindings.md @@ -1,11 +1,13 @@ -# JS / TypeScript bindings for WinRT (`jsBindings` feature) +# JS / TypeScript bindings for WinRT (`winapp.jsBindings` feature) `winapp` can generate typed JavaScript + TypeScript wrappers for Windows Runtime APIs as part of the standard `init` / `restore` flow. The generator runs on top of [dynwinrt](https://github.com/microsoft/dynwinrt) — a runtime FFI bridge that calls WinRT methods via `.winmd` metadata, so the produced bindings are **typed at compile time** but call WinRT **dynamically at runtime** (no native build step required from your project). -This document covers the user-facing CLI flow, the `winapp.yaml` schema, recipes for common scenarios, and a brief description of what happens under the hood. +This document covers the user-facing CLI flow, the `package.json` schema, recipes for common scenarios, and a brief description of what happens under the hood. > **Availability** — JS/TS bindings are gated behind invocation via the `@microsoft/winappcli` npm package (i.e. `npx winapp …`). The interactive bindings prompt on `winapp init` only appears when invoked through the npm shim, because the binding generator (`@microsoft/dynwinrt-codegen`) and the runtime (`@microsoft/dynwinrt`) ship as npm dependencies. The standalone winget / installer build does not surface the prompt. +> **Configuration lives in `package.json`, not `winapp.yaml`.** `winapp.yaml` is owned by the native CLI and only describes SDK package pins; the JS bindings schema lives under `"winapp": { "jsBindings": {...} }` in `package.json` — the same convention used by `eslint`, `jest`, `prettier`, `tsup`, etc. The native CLI has zero awareness of JS bindings. + --- ## Quick start @@ -14,14 +16,14 @@ The fastest path to "I want to call WinAppSDK / Windows Runtime APIs from my Nod ```bash npm i -D @microsoft/winappcli -npx winapp init --use-defaults # auto-picks "Both" (C++ projections + JS bindings) +npx winapp init --use-defaults # auto-opts in to JS bindings npm install # picks up the @microsoft/dynwinrt runtime dep that init injected ``` -That gives you `bindings/winrt/*.js` + `*.d.ts` for the full Windows App SDK surface, ready to import: +That gives you `bindings/*.js` + `*.d.ts` for the full Windows App SDK surface, ready to import: ```ts -import { LanguageModel } from './bindings/winrt/Microsoft.Windows.AI.Generative.LanguageModel'; +import { LanguageModel } from './bindings/Microsoft.Windows.AI.Generative.LanguageModel'; const model = await LanguageModel.createAsync(); ``` @@ -29,17 +31,20 @@ Want the interactive prompt instead? Omit `--use-defaults`: ```bash npx winapp init -# > Bindings to generate: -# C++ projections -# JS/TS bindings -# ❯ Both (default) +# > Add JS/TypeScript bindings to this project? [Y/n]: ``` -Already have a `winapp.yaml` and just want to add bindings on top? Edit the yaml to add an empty `jsBindings: {}` block (or a scoped one — see [workflow #3](#3-slice-generation-by-nuget-package)) and run `npx winapp restore`: - -```yaml -# winapp.yaml -jsBindings: {} # full Windows App SDK surface +Already have a workspace and just want to add bindings on top? Edit `package.json` to add a `winapp.jsBindings` namespace (an empty object means "full Windows App SDK surface — defaults"), then run `npx winapp restore`: + +```jsonc +// package.json +{ + "name": "my-electron-app", + "version": "0.1.0", + "winapp": { + "jsBindings": {} + } +} ``` ```bash @@ -50,198 +55,216 @@ npx winapp restore ## Common workflows -> The yaml snippets below show only the fields each workflow touches. For the complete `jsBindings:` schema (every field, default values, type, composition rules), see [`winapp.yaml` — `jsBindings:` block](#winappyaml--jsbindings-block). +> The JSON snippets below show only the fields each workflow touches. For the complete `winapp.jsBindings` schema (every field, default values, type, composition rules), see [`package.json` — `winapp.jsBindings` namespace](#packagejson--winappjsbindings-namespace). ### 1. Generate bindings for the full WinAppSDK surface -```yaml -# winapp.yaml -jsBindings: {} +```jsonc +// package.json +{ + "winapp": { + "jsBindings": {} + } +} ``` -The empty block accepts the defaults: `lang: js`, `output: bindings/winrt`, and `packages: []` which means **every installed package's `.winmd` files participate**. Convenient for exploration; for a shipping app you may want to narrow `packages:` to just the APIs you actually call. +The empty block accepts the defaults: `lang: "js"`, `output: "bindings"`, and `packages: []` which means **every installed package's `.winmd` files participate**. Convenient for exploration; for a shipping app you may want to narrow `packages` to just the APIs you actually call. > XAML namespaces (`Microsoft.UI.Xaml.*`, `Windows.UI.Xaml.*`) are out of scope for dynwinrt — the codegen itself classifies them automatically as resolution-only refs, so no JS gets emitted for them regardless of which packages are in scope. -### 2. Skip C++ projections (JS-only project) - -If your app is pure Node/Electron and you don't need cppwinrt headers, the bindings prompt's **JS/TS bindings** option (or `cppProjections: false` in `winapp.yaml`) skips the ~130 MB / ~20 s cppwinrt step entirely: - -```yaml -# winapp.yaml -cppProjections: false -jsBindings: {} -``` - -```bash -npx winapp restore -``` - -### 3. Slice generation by NuGet package +### 2. Slice generation by NuGet package When you don't want bindings for every installed package, list the NuGet package IDs you actually want bindings for: -```yaml -# winapp.yaml -jsBindings: - output: bindings/winrt - packages: - - Microsoft.WindowsAppSDK.AI # AI APIs only - - Microsoft.WindowsAppSDK # full WinAppSDK on top +```jsonc +// package.json +{ + "winapp": { + "jsBindings": { + "output": "bindings", + "packages": [ + "Microsoft.WindowsAppSDK.AI", // AI APIs only + "Microsoft.WindowsAppSDK" // full WinAppSDK on top + ] + } + } +} ``` -Each entry must match a NuGet package ID present under your top-level `packages:` block. Empty / omitted means "all installed packages participate" (the default). - -> Earlier versions supported namespace-prefix slicing (`includeNamespacePrefixes:` / `excludeNamespacePrefixes:`). Slicing now happens at the package level — coarser, but matches how WinRT metadata is actually shipped. - -### 4. Add your own / a vendor `.winmd` - -```yaml -# winapp.yaml -jsBindings: - output: bindings/winrt - additionalWinmds: - - vendor/MyCompany.Foo.winmd # relative to workspace root - - C:\shared\OtherSdk.winmd # absolute also works +Each entry must match a NuGet package ID present under your `winapp.yaml` top-level `packages:` block. Empty / omitted means "all installed packages participate" (the default). + +> Earlier versions supported namespace-prefix slicing. Slicing now happens at the package level — coarser, but matches how WinRT metadata is actually shipped. + +### 3. Add your own / a vendor `.winmd` + +```jsonc +// package.json +{ + "winapp": { + "jsBindings": { + "output": "bindings", + "additionalWinmds": [ + "vendor/MyCompany.Foo.winmd", // relative to workspace root + "C:/shared/OtherSdk.winmd" // absolute also works + ] + } + } +} ``` -`additionalWinmds:` files are appended to the codegen input alongside the package-discovered winmds. Use this when you want bindings emitted for the entire vendor file. - -### 5. Cherry-pick a few classes from a giant vendor SDK - -```yaml -jsBindings: - output: bindings/winrt - additionalRefs: # load for resolution only — NO bulk emit - - vendor/BigVendor.SDK.winmd - extraTypes: # explicitly list classes to emit - - namespace: BigVendor.Camera - classes: - - Lens - - Sensor +`additionalWinmds` files are appended to the codegen input alongside the package-discovered winmds. Use this when you want bindings emitted for the entire vendor file. + +### 4. Cherry-pick a few classes from a giant vendor SDK + +```jsonc +{ + "winapp": { + "jsBindings": { + "output": "bindings", + "additionalRefs": [ // load for resolution only — NO bulk emit + "vendor/BigVendor.SDK.winmd" + ], + "extraTypes": [ // explicitly list classes to emit + { + "namespace": "BigVendor.Camera", + "classes": ["Lens", "Sensor"] + } + ] + } + } +} ``` This is the right pattern when the vendor ships a 200 MB winmd and you only want two classes. The codegen loads the metadata for type resolution but only emits bindings for `Lens` and `Sensor`. The same pattern works for cherry-picking from system `Windows.*` winmds, which the codegen always treats as refs. -> If the same path appears in both `additionalWinmds:` and `additionalRefs:`, `additionalWinmds:` wins (emission is the stronger intent). +> If the same path appears in both `additionalWinmds` and `additionalRefs`, `additionalWinmds` wins (emission is the stronger intent). -### 6. Override the output directory +### 5. Override the output directory -```yaml -jsBindings: - output: src/generated/winrt +```jsonc +{ + "winapp": { + "jsBindings": { + "output": "src/generated/winrt" + } + } +} ``` -### 7. Re-run codegen after editing `winapp.yaml` +### 6. Re-run codegen after editing `package.json` -Any time you edit the `jsBindings:` block (add a package, swap to a different scope, add an `extraTypes:` entry), re-run: +Any time you edit the `winapp.jsBindings` namespace (add a package, swap to a different scope, add an `extraTypes` entry), re-run: ```bash npx winapp restore ``` -`restore` reads the existing yaml without modifying it, re-discovers winmds, and re-runs codegen — the output directory is replaced atomically (stage-then-swap; previous bindings are preserved on codegen failure). +`restore` reads the existing JSON without modifying it, re-discovers winmds, and re-runs codegen — the output directory is replaced atomically (stage-then-swap; previous bindings are preserved on codegen failure). --- -## `winapp.yaml` — `jsBindings:` block +## `package.json` — `winapp.jsBindings` namespace Full schema with every field shown explicitly: -```yaml -packages: - - name: Microsoft.WindowsAppSDK - version: 1.8.39 - - name: Microsoft.WindowsAppSDK.AI - version: 0.4.250712-experimental2 - -# Skip cppwinrt headers/libs/runtimes/projection generation. Defaults to true -# (C++ projections enabled). Set to false for pure Node/Electron projects that -# only consume JS bindings. -cppProjections: false - -jsBindings: - # Target language — currently only 'js' (emits both .js and .d.ts). - # 'py' is supported in the underlying codegen but not yet exposed here. - lang: js - - # Output directory for generated .js + .d.ts (relative to workspace root). - output: bindings/winrt - - # NuGet package IDs to scope binding generation to. When non-empty, only - # .winmd files from these packages flow into the codegen (everything else - # under the top-level `packages:` block is still installed for the C# / - # native build, just not turned into JS bindings). Each entry must match - # a package ID present in the top-level `packages:` block. - # When empty / omitted, every installed package participates. - packages: - - Microsoft.WindowsAppSDK.AI - - # Extra .winmd files to feed into the codegen alongside package-discovered - # ones. Each entry is bulk-emitted (gets full bindings). - # Paths: relative to workspace root, OR absolute. Missing files = warning. - additionalWinmds: - - vendor/MyCompany.Foo.winmd - - C:\shared\OtherSdk.winmd - - # Like additionalWinmds, but LOAD-ONLY: the metadata is available for - # resolution (and for extraTypes lookups below) but no bulk emit happens. - # Pair with extraTypes to cherry-pick from large vendor SDKs. - additionalRefs: - - vendor/BigVendor.SDK.winmd - - # Per-class explicit picks. Searches across all loaded winmds (package + - # additionalWinmds + additionalRefs + system Windows.*). Useful for grabbing - # one or two classes from a winmd you don't want fully emitted. - extraTypes: - - namespace: BigVendor.Camera - classes: - - Lens - - Sensor - - # ── Per-package classification overrides ────────────────────────────── - # Layered on top of the built-in default policy (WinUI = skip, - # InteractiveExperiences = ref-only). Useful when MS introduces a new XAML - # package or you want to force-emit a normally-denylisted one. - - # Force-skip: drop entirely, no .js emit, not loaded as ref either. - skipPackages: - - Some.New.WinUI.Package - - # Force-ref-only: load for type resolution (--ref channel) but no .js emit. - refOnlyPackages: - - Vendor.PrimitiveTypes - - # Force-emit: overrides default skip / ref-only / user skip / user ref-only. - # Use to opt back in to a denylisted package for experimentation. - emitPackages: - - Microsoft.WindowsAppSDK.WinUI +```jsonc +// package.json +{ + "name": "my-electron-app", + "version": "0.1.0", + "winapp": { + "jsBindings": { + // Target language — currently only "js" (emits both .js and .d.ts). + // "py" is supported in the underlying codegen but not yet exposed here. + "lang": "js", + + // Output directory for generated .js + .d.ts (relative to workspace root). + "output": "bindings", + + // NuGet package IDs to scope binding generation to. When non-empty, only + // .winmd files from these packages flow into the codegen (everything + // else under winapp.yaml's top-level `packages:` block is still + // installed for the C++ projections, just not turned into JS bindings). + // Each entry must match a package ID present in winapp.yaml's + // `packages:` block. When empty / omitted, every installed package + // participates. + "packages": [ + "Microsoft.WindowsAppSDK.AI" + ], + + // Extra .winmd files to feed into the codegen alongside package-discovered + // ones. Each entry is bulk-emitted (gets full bindings). + // Paths: relative to workspace root, OR absolute. Missing files = warning. + "additionalWinmds": [ + "vendor/MyCompany.Foo.winmd", + "C:/shared/OtherSdk.winmd" + ], + + // Like additionalWinmds, but LOAD-ONLY: the metadata is available for + // resolution (and for extraTypes lookups below) but no bulk emit happens. + // Pair with extraTypes to cherry-pick from large vendor SDKs. + "additionalRefs": [ + "vendor/BigVendor.SDK.winmd" + ], + + // Per-class explicit picks. Searches across all loaded winmds (package + + // additionalWinmds + additionalRefs + system Windows.*). Useful for grabbing + // one or two classes from a winmd you don't want fully emitted. + "extraTypes": [ + { + "namespace": "BigVendor.Camera", + "classes": ["Lens", "Sensor"] + } + ], + + // ── Per-package classification overrides ───────────────────────────── + // Layered on top of the built-in default policy (WinUI = skip, + // InteractiveExperiences = ref-only). Useful when MS introduces a new + // XAML package or you want to force-emit a normally-denylisted one. + + // Force-skip: drop entirely, no .js emit, not loaded as ref either. + "skipPackages": [ + "Some.New.WinUI.Package" + ], + + // Force-ref-only: load for type resolution (--ref channel) but no .js emit. + "refOnlyPackages": [ + "Vendor.PrimitiveTypes" + ], + + // Force-emit: overrides default skip / ref-only / user skip / user ref-only. + // Use to opt back in to a denylisted package for experimentation. + "emitPackages": [ + "Microsoft.WindowsAppSDK.WinUI" + ] + } + } +} ``` ### Field defaults at a glance | Field | Default | Type | |-------|---------|------| -| `cppProjections` (top-level) | `true` | bool | -| `jsBindings.lang` | `js` | string | -| `jsBindings.output` | `bindings/winrt` | string | -| `jsBindings.packages` | `[]` (= all installed packages) | list of NuGet IDs | -| `jsBindings.additionalWinmds` | `[]` | list of paths | -| `jsBindings.additionalRefs` | `[]` | list of paths | -| `jsBindings.extraTypes` | `[]` | list of `{namespace, classes[]}` | -| `jsBindings.skipPackages` | `[]` | list of NuGet IDs | -| `jsBindings.refOnlyPackages` | `[]` | list of NuGet IDs | -| `jsBindings.emitPackages` | `[]` | list of NuGet IDs | +| `lang` | `"js"` | string | +| `output` | `"bindings"` | string | +| `packages` | `[]` (= all installed packages) | array of NuGet IDs | +| `additionalWinmds` | `[]` | array of paths | +| `additionalRefs` | `[]` | array of paths | +| `extraTypes` | `[]` | array of `{namespace, classes[]}` | +| `skipPackages` | `[]` | array of NuGet IDs | +| `refOnlyPackages` | `[]` | array of NuGet IDs | +| `emitPackages` | `[]` | array of NuGet IDs | ### Composition rules (when multiple lists overlap) The codegen applies these rules in order: -1. **Package scope** — if `packages:` is non-empty, only winmds inside those NuGet packages are taken from the package set; otherwise every installed package's winmds are taken. (Top-level `packages:` is the source of truth for what's installed; `jsBindings.packages` only filters which subset participates in JS-binding generation.) +1. **Package scope** — if `packages` is non-empty, only winmds inside those NuGet packages are taken from the package set; otherwise every installed package's winmds are taken. (`winapp.yaml`'s `packages:` block is the source of truth for what's installed; `winapp.jsBindings.packages` only filters which subset participates in JS-binding generation.) 2. **Per-package classification** — each in-scope package is classified into `emit` / `refOnly` / `skip` using the precedence:
**user `emitPackages` ⟶ default-skip ∪ user `skipPackages` ⟶ default-ref-only ∪ user `refOnlyPackages` ⟶ emit**.
Skip drops the winmd; ref-only routes it through `--ref`; emit produces JS bindings. -3. `additionalWinmds:` and `additionalRefs:` paths are appended to the codegen input. If a file is in both lists, `additionalWinmds:` wins. -4. **Auto-classification by codegen** — `Windows.*` system winmds (and any other namespace the codegen treats as a foundation namespace) are loaded as resolution-only refs even when you list them under `additionalWinmds:`. They will not produce JS files in bulk mode; use `extraTypes:` to pull individual classes out. -5. `extraTypes:` runs as a separate pass after the bulk pass — it can pull classes out of any loaded winmd (refs included). +3. `additionalWinmds` and `additionalRefs` paths are appended to the codegen input. If a file is in both lists, `additionalWinmds` wins. +4. **Auto-classification by codegen** — `Windows.*` system winmds (and any other namespace the codegen treats as a foundation namespace) are loaded as resolution-only refs even when you list them under `additionalWinmds`. They will not produce JS files in bulk mode; use `extraTypes` to pull individual classes out. +5. `extraTypes` runs as a separate pass after the bulk pass — it can pull classes out of any loaded winmd (refs included). --- @@ -249,39 +272,42 @@ The codegen applies these rules in order: When `init` (or `restore`) runs the JS-bindings step on a workspace, the CLI: -1. Detects your project's package manager from the `packageManager:` field in `package.json`, then falls back to lockfile sniffing (`pnpm-lock.yaml` → pnpm, `yarn.lock` → yarn, `bun.lockb` → bun, `package-lock.json` → npm). Defaults to **npm** if no signal exists. +1. Detects your project's package manager from the `packageManager` field in `package.json`, then falls back to lockfile sniffing (`pnpm-lock.yaml` → pnpm, `yarn.lock` → yarn, `bun.lockb` → bun, `package-lock.json` → npm). Defaults to **npm** if no signal exists. 2. Adds `@microsoft/dynwinrt` to your `package.json` `dependencies` (production dep, NOT devDep) — your generated bindings `import` from it at module load, so it must ship in your installed app. 3. Prints a PM-aware install hint (`npm install` / `pnpm install` / `yarn install` / `bun install`) so you know what to run next. Supported package managers: **npm, pnpm, yarn, bun**. -> Why production not devDep? `@microsoft/dynwinrt` provides the runtime FFI bridge — without it, your generated `bindings/winrt/*.js` files fail to load at runtime. It's not a build-only tool. +> Why production not devDep? `@microsoft/dynwinrt` provides the runtime FFI bridge — without it, your generated `bindings/*.js` files fail to load at runtime. It's not a build-only tool. --- ## How it works under the hood ``` - ┌─────────────────────┐ - │ winapp.yaml │ — packages: + jsBindings: blocks - └──────────┬──────────┘ - │ (init / restore) - ▼ + ┌─────────────────────┐ ┌─────────────────────────────┐ + │ winapp.yaml │ │ package.json │ + │ (native CLI owns) │ │ "winapp": { "jsBindings" } │ + │ packages: ... │ │ (npm wrapper owns) │ + └──────────┬──────────┘ └──────────────┬──────────────┘ + │ │ + │ (winapp restore) │ (npm wrapper post-restore) + ▼ ▼ ┌──────────────────────────────────────────┐ - │ WorkspaceSetupService │ + │ WorkspaceSetupService (native) │ │ • restore NuGet packages │ - │ • discover .winmd files in installed │ - │ packages, scoped by │ - │ jsBindings.packages if set │ - │ • resolve additionalWinmds / │ - │ additionalRefs paths │ - └──────────┬───────────────────────────────┘ + │ • discover .winmd files │ + │ • write .winapp/winmds.lock.json │ + │ • generate cppwinrt projections │ + └──────────────────────────────────────────┘ │ - ▼ + ▼ (npm wrapper sees winapp.jsBindings in package.json) ┌──────────────────────────────────────────┐ - │ DynWinrtCodegenService │ + │ JS bindings orchestrator (npm wrapper) │ │ • partition winmds: emit / ref-only / │ - │ skip (per JsBindingsPresets policy) │ + │ skip (per built-in winmd-policy) │ + │ • resolve additionalWinmds / │ + │ additionalRefs paths │ │ • safety-check output dir │ │ (.dynwinrt-managed marker) │ │ • spawn @microsoft/dynwinrt-codegen │ @@ -295,7 +321,7 @@ Supported package managers: **npm, pnpm, yarn, bun**. │ • loads emit winmds + ref winmds │ │ • auto-classifies Windows.* as │ │ resolution-only refs │ - │ • generates .js + .d.ts │ → bindings/winrt/..{js,d.ts} + │ • generates .js + .d.ts │ → bindings/..{js,d.ts} └──────────────────────────────────────────┘ │ (at app runtime) ▼ @@ -316,15 +342,15 @@ Some WinAppSDK packages ship `.winmd` files that dynwinrt cannot drive at runtim | `Microsoft.WindowsAppSDK.InteractiveExperiences` | **Ref-only** | Ships `Microsoft.UI.WindowId`, `Microsoft.Graphics.PointInt32`, `Microsoft.UI.Color` and other primitive types widely referenced by Foundation/Storage/Notifications APIs — must stay loaded for type resolution, but its own runtime classes are XAML/Composition types winapp cannot drive. | | Everything else | **Emit** | Bulk-generate JS bindings (codegen still auto-classifies `Windows.*` as refs internally). | -This split happens in `JsBindingsPresets.PartitionByPackageCategory`. Skipped winmds aren't passed to the codegen at all; ref-only winmds flow through the codegen `--ref` channel. +This split happens in the npm wrapper's `winmd-policy.ts` (`partitionByPackageCategory`). Skipped winmds aren't passed to the codegen at all; ref-only winmds flow through the codegen `--ref` channel. -**Escape hatch**: if you need the contents of a Skip/Ref-only package (vendor fork, experimentation), list its winmd files explicitly under `jsBindings.additionalWinmds:` — those flow through the user-additional channel and bypass the policy above. +**Escape hatch**: if you need the contents of a Skip/Ref-only package (vendor fork, experimentation), list its winmd files explicitly under `winapp.jsBindings.additionalWinmds` — those flow through the user-additional channel and bypass the policy above. ### The `.dynwinrt-managed` marker and `winmds.lock.json` After a successful generation winapp writes `/.dynwinrt-managed` into the output directory. Its presence is the **only** signal winapp uses to know that a non-empty output directory is safe to wipe before the next codegen run. **Never delete this file by hand**, and never put files you care about under the codegen output directory — the next `restore` will wipe anything other than the marker. (If the directory is non-empty and the marker is missing, winapp aborts rather than risk overwriting hand-written code.) -In addition, `winapp restore` writes `.winapp/winmds.lock.json` — a human-readable snapshot of every NuGet package the restore resolved, with its version, the per-package winmd discovery results, and the `JsBindingsPresets` category (`emit` / `refOnly` / `skip`). The lockfile is purely a diagnostic artifact: +In addition, `winapp restore` writes `.winapp/winmds.lock.json` — a human-readable snapshot of every NuGet package the restore resolved, with its version and the per-package winmd discovery results. The lockfile is the bridge between the native `winapp restore` (which writes it) and the npm wrapper (which reads it and applies the emit/refOnly/skip policy at codegen time). It's also a useful diagnostic artifact: - Useful to share when reporting bugs: it tells the maintainer exactly what got resolved without us needing to repro your NuGet feed setup. - Records a SHA-256 of the top-level `packages:` block so you can spot yaml drift between restore runs. @@ -338,12 +364,13 @@ In addition, `winapp restore` writes `.winapp/winmds.lock.json` — a human-read | Symptom | Cause / fix | |---------|-------------| | `winapp init` doesn't show a bindings prompt | You ran the standalone `winapp` (winget / installer). JS bindings ship as an npm-only feature. Install via `npm i -D @microsoft/winappcli` and call as `npx winapp init` to get the prompt. | -| `bindings/winrt/` is empty after restore | Most likely your `packages:` slice is too narrow, or matches no installed package. Check the debug log (`-v debug`) for the `winmd partition: emit=… ref-only=… skipped=…` line to see what got passed to the codegen. | -| Cannot find a class you expect | The codegen auto-classifies `Windows.*` (and similar foundation namespaces) as refs and does not bulk-emit them. Use `extraTypes:` to pull individual classes out: `{ namespace: 'Windows.Foundation', classes: ['Uri'] }`. | -| `winapp` refuses to write into the output directory | The output directory is non-empty and lacks a `.dynwinrt-managed` marker — winapp won't wipe it because it might contain hand-written code. Either point `output:` somewhere else, or delete the directory yourself if you're sure. | +| `bindings/` is empty after restore | Most likely your `packages` slice is too narrow, or matches no installed package. Check the debug log (`-v debug`) for the `winmd partition: emit=… ref-only=… skipped=…` line to see what got passed to the codegen. | +| Cannot find a class you expect | The codegen auto-classifies `Windows.*` (and similar foundation namespaces) as refs and does not bulk-emit them. Use `extraTypes` to pull individual classes out: `{ "namespace": "Windows.Foundation", "classes": ["Uri"] }`. | +| `winapp` refuses to write into the output directory | The output directory is non-empty and lacks a `.dynwinrt-managed` marker — winapp won't wipe it because it might contain hand-written code. Either point `output` somewhere else, or delete the directory yourself if you're sure. | | Imports from `@microsoft/dynwinrt` fail at app runtime | Make sure you ran your package manager's install command after `init` / `restore` (so the auto-injected production dep actually downloads). The CLI prints the right command for your PM in the output. | -| Vendor winmd not found | `additionalWinmds:` / `additionalRefs:` paths are workspace-relative or absolute. Missing files print a warning and are skipped (so a stale entry doesn't break a working restore) — re-check the path. | -| Want bindings but already ran `init` without them | Edit `winapp.yaml`, add `jsBindings: {}` (and optionally `cppProjections: false` if you don't want C++ projections), then run `npx winapp restore`. | +| Vendor winmd not found | `additionalWinmds` / `additionalRefs` paths are workspace-relative or absolute. Missing files print a warning and are skipped (so a stale entry doesn't break a working restore) — re-check the path. | +| Want bindings but already ran `init` without them | Edit `package.json`, add `"winapp": { "jsBindings": {} }`, then run `npx winapp restore`. | +| `package.json not found` when adding bindings | Run `npm init -y` (or your package manager's equivalent) first so the file exists, then re-run `npx winapp init`. | --- @@ -351,4 +378,5 @@ In addition, `winapp restore` writes `.winapp/winmds.lock.json` — a human-read - [`@microsoft/dynwinrt`](https://github.com/microsoft/dynwinrt) — the runtime FFI bridge - [`@microsoft/dynwinrt-codegen`](https://github.com/microsoft/dynwinrt) — the code-generation tool (lives in the same repo as `dynwinrt`) -- `winapp.yaml` schema reference (top-level): `packages:`, `cppProjections:`, `jsBindings:` +- `winapp.yaml` schema reference (top-level, native-owned): only `packages:` +- `package.json` `winapp.jsBindings` namespace reference (npm-wrapper-owned): everything documented above diff --git a/docs/npm-usage.md b/docs/npm-usage.md index 320b78d9..ae2abe1e 100644 --- a/docs/npm-usage.md +++ b/docs/npm-usage.md @@ -189,7 +189,7 @@ function getWinappPath(options?: GetWinappPathOptions): Promise ### `init()` -Start here for initializing a Windows app with required setup. Sets up everything needed for Windows app development: creates Package.appxmanifest with default assets, downloads Windows SDK and Windows App SDK packages, and generates projections. When SDK packages are managed (--setup-sdks stable/preview/experimental), also creates winapp.yaml to pin versions for 'restore'/'update'; with --setup-sdks none (e.g., for Rust/Tauri projects that bring their own SDK bindings), no winapp.yaml is created. When invoked via the \@microsoft/winappcli npm package (npx winapp init), additionally asks whether to generate C++ projections, JS/TS bindings, or both. Interactive by default (use --use-defaults to skip prompts). Use 'restore' instead if you cloned a repo that already has winapp.yaml. Use 'manifest generate' if you only need a manifest, or 'cert generate' if you need a development certificate for code signing. +Start here for initializing a Windows app with required setup. Sets up everything needed for Windows app development: creates Package.appxmanifest with default assets, downloads Windows SDK and Windows App SDK packages, and generates projections. When SDK packages are managed (--setup-sdks stable/preview/experimental), also creates winapp.yaml to pin versions for 'restore'/'update'; with --setup-sdks none (e.g., for Rust/Tauri projects that bring their own SDK bindings), no winapp.yaml is created. Interactive by default (use --use-defaults to skip prompts). Use 'restore' instead if you cloned a repo that already has winapp.yaml. Use 'manifest generate' if you only need a manifest, or 'cert generate' if you need a development certificate for code signing. ```typescript function init(options?: InitOptions): Promise diff --git a/docs/usage.md b/docs/usage.md index 72da7735..d4746cab 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -38,13 +38,15 @@ winapp init [base-directory] [options] **JS/TypeScript bindings (npm only):** -When invoked via the `@microsoft/winappcli` npm package (i.e. `npx winapp init` inside a Node / Electron project), `init` adds an interactive **bindings prompt** that offers: +When invoked via the `@microsoft/winappcli` npm package (i.e. `npx winapp init` inside a Node / Electron project), `init` adds an interactive **bindings prompt**: -- **C++ projections** — generate cppwinrt headers/libs/runtimes (the standalone default) -- **JS/TS bindings** — generate typed JS/TypeScript wrappers via `dynwinrt-codegen`, skip C++ projections -- **Both** — generate both (default with `--use-defaults`) +``` +Add JS/TypeScript bindings to this project? [Y/n]: +``` + +Picking **Yes** writes a default `"winapp.jsBindings"` namespace to `package.json` and runs `dynwinrt-codegen` to emit JS/TS wrappers. C++ projections (cppwinrt headers/libs/runtimes) are generated either way — there is no "JS only" mode; the JS bindings are an addition on top of the standard native workspace. Subsequent `winapp restore` calls re-run codegen against the pinned packages. -Picking JS or Both writes a default `jsBindings:` block to `winapp.yaml` covering the full Windows App SDK; subsequent `winapp restore` calls re-run codegen against the pinned packages. See the [JS bindings reference](js-bindings.md) for the full schema (`packages:`, `skip:`, `refOnly:`, `extraTypes:`, etc.) and the [Electron JS bindings guide](guides/electron/jsbindings.md) for the end-to-end workflow. +See the [JS bindings reference](js-bindings.md) for the full schema (`packages`, `skip`, `refOnly`, `extraTypes`, etc.) and the [Electron JS bindings guide](guides/electron/jsbindings.md) for the end-to-end workflow. **What it does:** @@ -159,26 +161,26 @@ winapp update --setup-sdks experimental ### JS/TypeScript bindings (via `init` / `restore`) -JS/TS bindings are configured by declaring a `jsBindings:` block in `winapp.yaml` and generated by `winapp init` (first run) or `winapp restore` (subsequent runs). There is no separate `node jsbindings` sub-command — the flow is unified with the rest of the workspace lifecycle: +JS/TS bindings are configured by declaring a `"winapp": { "jsBindings": {...} }` namespace in **`package.json`** and generated by `winapp init` (first run) or `winapp restore` (subsequent runs). There is no separate `node jsbindings` sub-command — the flow is unified with the rest of the workspace lifecycle: | Want to … | Command | |---|---| -| Bootstrap a fresh workspace with bindings | `npx winapp init` (pick **JS** or **Both** at the prompt; default with `--use-defaults` is Both) | -| Add `jsBindings:` to an existing workspace | Edit `winapp.yaml` to add a `jsBindings:` block (e.g. `jsBindings: {}` for the full SDK), then run `npx winapp restore` | -| Re-run codegen after editing `jsBindings:` | `npx winapp restore` | +| Bootstrap a fresh workspace with bindings | `npx winapp init` (answer **Y** at the prompt; default is **Y**) | +| Add JS bindings to an existing workspace | Edit `package.json` to add `"winapp": { "jsBindings": {} }` (the empty object opts in with full-SDK defaults), then run `npx winapp restore` | +| Re-run codegen after editing `winapp.jsBindings` | `npx winapp restore` | | Re-run codegen as part of full restore (also handles NuGet + cppwinrt) | `npx winapp restore` | -**What runs during `restore` when `jsBindings:` is declared:** +**What runs during `restore` when `winapp.jsBindings` is declared:** -- Reads the existing `jsBindings:` block from `winapp.yaml` (no mutation) +- Reads the existing `winapp.jsBindings` namespace from `package.json` (no mutation) - Resolves winmds via NuGet cache walk + transitive-deps expansion -- Spawns `@microsoft/dynwinrt-codegen` to emit `.js` + `.d.ts` into the configured `jsBindings.output` directory (default `bindings/winrt/`) +- Spawns `@microsoft/dynwinrt-codegen` to emit `.js` + `.d.ts` into the configured `output` directory (default `bindings/`) - Replaces the previous output dir atomically (stage-then-swap); previous bindings are preserved on codegen failure - Auto-injects `@microsoft/dynwinrt` as a production dep in your `package.json` so generated bindings can `import` it at runtime JS/TS bindings are **npm-only** — they require invocation via the `@microsoft/winappcli` npm package because they pull in `@microsoft/dynwinrt-codegen` as a transitive dep. The interactive bindings prompt during `init` only fires when invoked via the npm shim (`npx winapp …`); the standalone winget CLI does not surface it. -> See [JS bindings docs](js-bindings.md) for the full `jsBindings:` yaml schema, per-package winmd categorization (skip / refOnly / emit overrides), and the `winmds.lock.json` audit artifact. +> See [JS bindings docs](js-bindings.md) for the full `winapp.jsBindings` schema, per-package winmd categorization (skip / refOnly / emit overrides), and the `winmds.lock.json` audit artifact. --- diff --git a/samples/electron/package.json b/samples/electron/package.json index 756005b0..0b3127db 100644 --- a/samples/electron/package.json +++ b/samples/electron/package.json @@ -39,5 +39,8 @@ "nan": "^2.24.0", "node-addon-api": "^8.5.0", "node-gyp": "^12.1.0" + }, + "winapp": { + "jsBindings": {} } } diff --git a/samples/electron/test.Tests.ps1 b/samples/electron/test.Tests.ps1 index 8d57e402..f3ec5b63 100644 --- a/samples/electron/test.Tests.ps1 +++ b/samples/electron/test.Tests.ps1 @@ -111,12 +111,13 @@ Describe "Electron Sample" { } It "Should initialize winapp workspace with JS bindings and C++ projections" -Skip:$script:skip { - # `init --use-defaults` invoked via the npm shim auto-picks "Both" - # at the bindings prompt (C++ projections + JS/TS bindings) and - # runs codegen in one step. No yaml flag is needed; the prompt - # only fires when WINAPP_CLI_CALLER=nodejs-package (set by the - # `npx winapp` shim, which Invoke-WinappCommand resolves to here - # after Install-WinappNpmPackage). + # `init --use-defaults` invoked via the npm shim auto-answers Yes + # at the bindings prompt (Add JS/TypeScript bindings? [Y/n]) and + # runs codegen in one step. C++ projections always run. The + # prompt only fires when WINAPP_CLI_CALLER=nodejs-package (set by + # the `npx winapp` shim, which Invoke-WinappCommand resolves to + # here after Install-WinappNpmPackage). Selecting Yes writes + # `"winapp": { "jsBindings": {} }` into package.json. Push-Location $script:appDir try { Invoke-WinappCommand -Arguments "init . --use-defaults --setup-sdks=stable" @@ -132,10 +133,10 @@ Describe "Electron Sample" { # ── JS bindings smoke (v2.x) ───────────────────────────────────── # Verify the npm-caller init path produced the expected bindings # output, lockfile, and runtime dep — and that re-running `restore` - # is idempotent (no winapp.yaml mutation). + # is idempotent (no winapp.yaml or package.json mutation). - It "Should have generated bindings/winrt/ with the managed marker" -Skip:$script:skip { - $bindingsDir = Join-Path $script:appDir "bindings\winrt" + It "Should have generated bindings/ with the managed marker" -Skip:$script:skip { + $bindingsDir = Join-Path $script:appDir "bindings" $bindingsDir | Should -Exist # Marker proves the staging-then-swap completed. (Join-Path $bindingsDir ".dynwinrt-managed") | Should -Exist @@ -165,21 +166,25 @@ Describe "Electron Sample" { $lockfile.packages | Should -Not -BeNullOrEmpty -Because "Lockfile should record discovered packages" } - It "Should re-run codegen via 'winapp restore' without mutating winapp.yaml" -Skip:$script:skip { - # `restore` is the read-only re-run path against pinned yaml — - # it must not modify winapp.yaml. Capture the yaml hash - # before/after to prove it. + It "Should re-run codegen via 'winapp restore' without mutating winapp.yaml or jsBindings" -Skip:$script:skip { + # `restore` is the read-only re-run path — it must not modify + # winapp.yaml or the winapp.jsBindings namespace in package.json. + # Capture both hashes before/after to prove it. $yamlPath = Join-Path $script:appDir "winapp.yaml" - $bindingsDir = Join-Path $script:appDir "bindings\winrt" - $hashBefore = (Get-FileHash -Path $yamlPath -Algorithm SHA256).Hash + $pkgPath = Join-Path $script:appDir "package.json" + $bindingsDir = Join-Path $script:appDir "bindings" + $yamlHashBefore = (Get-FileHash -Path $yamlPath -Algorithm SHA256).Hash + $pkgHashBefore = (Get-FileHash -Path $pkgPath -Algorithm SHA256).Hash Push-Location $script:appDir try { Invoke-WinappCommand -Arguments "restore" } finally { Pop-Location } - $hashAfter = (Get-FileHash -Path $yamlPath -Algorithm SHA256).Hash - $hashAfter | Should -Be $hashBefore -Because "restore must not mutate winapp.yaml" + $yamlHashAfter = (Get-FileHash -Path $yamlPath -Algorithm SHA256).Hash + $pkgHashAfter = (Get-FileHash -Path $pkgPath -Algorithm SHA256).Hash + $yamlHashAfter | Should -Be $yamlHashBefore -Because "restore must not mutate winapp.yaml" + $pkgHashAfter | Should -Be $pkgHashBefore -Because "restore must not mutate package.json (including winapp.jsBindings)" (Join-Path $bindingsDir ".dynwinrt-managed") | Should -Exist ` -Because "restore should leave the managed marker in place after regen" } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/BaseCommandTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/BaseCommandTests.cs index 37ad71ef..6d6ef03d 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/BaseCommandTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/BaseCommandTests.cs @@ -52,7 +52,6 @@ public void SetupBase() ConfigureServices(services) // Override services .AddSingleton(sp => new CurrentDirectoryProvider(_tempDirectory.FullName)) - .AddSingleton(new FakeNpmWrapperVersionProvider()) .AddSingleton(TestAnsiConsole) .AddLogging(b => { @@ -183,12 +182,4 @@ protected void DefaultAnswers() TestAnsiConsole.Input.PushKey(ConsoleKey.Enter); TestAnsiConsole.Input.PushKey(ConsoleKey.Enter); } - - // Stub INpmWrapperVersionProvider for tests so they don't try - // to walk up from the test runner exe path looking for the npm wrapper. - private sealed class FakeNpmWrapperVersionProvider : INpmWrapperVersionProvider - { - public string DynWinrtVersion => "0.0.0-test"; - public string DynWinrtCodegenVersion => "0.0.0-test"; - } } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/ConfigServiceJsBindingsTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/ConfigServiceJsBindingsTests.cs deleted file mode 100644 index 97a55b67..00000000 --- a/src/winapp-CLI/WinApp.Cli.Tests/ConfigServiceJsBindingsTests.cs +++ /dev/null @@ -1,811 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using WinApp.Cli.Models; -using WinApp.Cli.Services; - -namespace WinApp.Cli.Tests; - -[TestClass] -public class ConfigServiceJsBindingsTests : BaseCommandTests -{ - private static readonly string[] _arr00 = ["Microsoft.WindowsAppSDK.AI"]; - private static readonly string[] _arr01 = ["Microsoft.WindowsAppSDK.AI", "Microsoft.WindowsAppSDK"]; - private static readonly string[] _arr02 = ["vendor/BigVendor.winmd", @"C:\abs\OtherSdk.winmd"]; - private static readonly string[] _arr03 = ["vendor/Foo.winmd"]; - private static readonly string[] _arr04 = ["Lens", "Sensor"]; - private static readonly string[] _arr05 = ["vendor/BigVendor.winmd", @"C:\shared\OtherCompany.SDK.winmd"]; - private static readonly string[] _arr06 = ["vendor/Foo.winmd", @"C:\abs\Bar.winmd"]; - private static readonly string[] _arr07 = ["vendor/MyCompany.Foo.winmd", @"C:\absolute\path\Other.winmd", "sibling.winmd"]; - private static readonly string[] _arr08 = ["Uri"]; - private static readonly string[] _arr09 = ["StorageFile"]; - private static readonly string[] _arr10 = ["BitmapDecoder"]; - private static readonly string[] _arr11 = ["StorageFile", "StorageFolder"]; - private static readonly string[] _arr12 = ["LimitedAccessFeatures"]; - private static readonly string[] _arr13 = ["Calendar"]; - private static readonly string[] _arr14 = ["Uri", "PropertyValue"]; - - [TestMethod] - public void Load_NoJsBindings_ReturnsNull() - { - // Arrange - File.WriteAllText(_configService.ConfigPath.FullName, """ - packages: - - name: Microsoft.WindowsAppSDK - version: 1.8.39 - """); - - // Act - var cfg = _configService.Load(); - - // Assert - Assert.AreEqual(1, cfg.Packages.Count); - Assert.IsNull(cfg.JsBindings, "JsBindings must be null when block is absent"); - } - - [TestMethod] - public void Load_MinimalJsBindings_AppliesDefaults() - { - File.WriteAllText(_configService.ConfigPath.FullName, """ - packages: - - name: Microsoft.WindowsAppSDK - version: 1.8.39 - jsBindings: - output: bindings/winrt - """); - - var cfg = _configService.Load(); - - Assert.IsNotNull(cfg.JsBindings); - Assert.AreEqual("js", cfg.JsBindings.Lang, "lang defaults to 'js'"); - Assert.AreEqual("bindings/winrt", cfg.JsBindings.Output); - Assert.AreEqual(0, cfg.JsBindings.ExtraTypes.Count); - // packages: defaults to empty == "all installed packages participate in - // binding generation". Preset application narrows this list. - Assert.AreEqual(0, cfg.JsBindings.Packages.Count, - "Packages list defaults to empty (no preset slicing) — codegen handles Windows.* ref-classification on its own."); - } - - [TestMethod] - public void Load_FullJsBindings_ParsesAllFields() - { - File.WriteAllText(_configService.ConfigPath.FullName, """ - packages: - - name: Microsoft.WindowsAppSDK - version: 1.8.39 - jsBindings: - lang: js - output: src/generated - packages: - - Microsoft.WindowsAppSDK.AI - extraTypes: - - namespace: Windows.Foundation - classes: - - Uri - - PropertyValue - - namespace: Windows.Globalization - classes: - - Calendar - """); - - var cfg = _configService.Load(); - - Assert.IsNotNull(cfg.JsBindings); - Assert.AreEqual("src/generated", cfg.JsBindings.Output); - CollectionAssert.AreEqual( - _arr00, - cfg.JsBindings.Packages); - - Assert.AreEqual(2, cfg.JsBindings.ExtraTypes.Count); - - Assert.AreEqual("Windows.Foundation", cfg.JsBindings.ExtraTypes[0].Namespace); - CollectionAssert.AreEqual(_arr14, cfg.JsBindings.ExtraTypes[0].Classes); - - Assert.AreEqual("Windows.Globalization", cfg.JsBindings.ExtraTypes[1].Namespace); - CollectionAssert.AreEqual(_arr13, cfg.JsBindings.ExtraTypes[1].Classes); - } - - [TestMethod] - public void Load_ExtraTypes_InlineFlowList_Parses() - { - // Inline flow form: classes: [X, Y] — equivalent to a block list. - File.WriteAllText(_configService.ConfigPath.FullName, """ - jsBindings: - output: generated-js - extraTypes: - - namespace: Windows.ApplicationModel - classes: [LimitedAccessFeatures] - - namespace: Windows.Storage - classes: [StorageFile, StorageFolder] - - namespace: Windows.Graphics.Imaging - classes: [BitmapDecoder] - """); - - var cfg = _configService.Load(); - - Assert.IsNotNull(cfg.JsBindings); - Assert.AreEqual(3, cfg.JsBindings.ExtraTypes.Count); - CollectionAssert.AreEqual(_arr12, cfg.JsBindings.ExtraTypes[0].Classes); - CollectionAssert.AreEqual(_arr11, cfg.JsBindings.ExtraTypes[1].Classes); - CollectionAssert.AreEqual(_arr10, cfg.JsBindings.ExtraTypes[2].Classes); - } - - [TestMethod] - public void Load_ExtraTypes_ScalarSingleClass_Parses() - { - // Scalar form (the legacy `systemTypes:` style some users wrote): - // classes: SingleClass — treat as a one-item list. - File.WriteAllText(_configService.ConfigPath.FullName, """ - jsBindings: - output: generated-js - extraTypes: - - namespace: Windows.Storage - classes: StorageFile - """); - - var cfg = _configService.Load(); - - Assert.IsNotNull(cfg.JsBindings); - Assert.AreEqual(1, cfg.JsBindings.ExtraTypes.Count); - CollectionAssert.AreEqual(_arr09, cfg.JsBindings.ExtraTypes[0].Classes); - } - - [TestMethod] - public void SaveAndLoad_RoundTripsJsBindings() - { - var original = new WinappConfig(); - original.SetVersion("Microsoft.WindowsAppSDK", "1.8.39"); - original.JsBindings = new JsBindingsConfig - { - Lang = "js", - Output = "bindings/winrt", - Packages = new() { "Microsoft.WindowsAppSDK.AI" }, - ExtraTypes = new() - { - new JsBindingsExtraType - { - Namespace = "Windows.Foundation", - Classes = new() { "Uri" }, - }, - }, - }; - - _configService.Save(original); - var roundTrip = _configService.Load(); - - Assert.IsNotNull(roundTrip.JsBindings); - Assert.AreEqual("js", roundTrip.JsBindings.Lang); - Assert.AreEqual("bindings/winrt", roundTrip.JsBindings.Output); - CollectionAssert.AreEqual( - _arr00, - roundTrip.JsBindings.Packages, - "Round-trip must preserve the packages slice exactly."); - Assert.AreEqual(1, roundTrip.JsBindings.ExtraTypes.Count); - Assert.AreEqual("Windows.Foundation", roundTrip.JsBindings.ExtraTypes[0].Namespace); - CollectionAssert.AreEqual(_arr08, roundTrip.JsBindings.ExtraTypes[0].Classes); - } - - [TestMethod] - public void Load_PackagesAfterJsBindings_StillParsesPackages() - { - // The yaml block order should not matter for top-level sections. - File.WriteAllText(_configService.ConfigPath.FullName, """ - jsBindings: - output: bindings/winrt - packages: - - name: Microsoft.WindowsAppSDK - version: 1.8.39 - """); - - var cfg = _configService.Load(); - - Assert.IsNotNull(cfg.JsBindings); - Assert.AreEqual(1, cfg.Packages.Count); - Assert.AreEqual("Microsoft.WindowsAppSDK", cfg.Packages[0].Name); - Assert.AreEqual("1.8.39", cfg.Packages[0].Version); - } - - [TestMethod] - public void Load_AdditionalWinmds_ParsesRelativeAndAbsolutePaths() - { - File.WriteAllText(_configService.ConfigPath.FullName, """ - packages: - - name: Microsoft.WindowsAppSDK - version: 1.8.39 - jsBindings: - output: bindings/winrt - additionalWinmds: - - vendor/MyCompany.Foo.winmd - - C:\absolute\path\Other.winmd - - sibling.winmd - """); - - var cfg = _configService.Load(); - - Assert.IsNotNull(cfg.JsBindings); - CollectionAssert.AreEqual( - _arr07, - cfg.JsBindings.AdditionalWinmds, - "AdditionalWinmds entries must round-trip in declaration order, accepting both relative and absolute paths"); - } - - [TestMethod] - public void Load_AdditionalWinmds_DedupesCaseInsensitive() - { - File.WriteAllText(_configService.ConfigPath.FullName, """ - packages: - - name: Microsoft.WindowsAppSDK - version: 1.8.39 - jsBindings: - output: bindings/winrt - additionalWinmds: - - vendor/Foo.winmd - - Vendor/foo.WINMD - - vendor/Bar.winmd - """); - - var cfg = _configService.Load(); - - Assert.IsNotNull(cfg.JsBindings); - Assert.AreEqual( - 2, - cfg.JsBindings.AdditionalWinmds.Count, - "Duplicate paths (case-insensitive) must be deduped to keep winmd list file deterministic"); - } - - [TestMethod] - public void SaveAndLoad_AdditionalWinmds_RoundTrips() - { - var original = new WinappConfig(); - original.SetVersion("Microsoft.WindowsAppSDK", "1.8.39"); - original.JsBindings = new JsBindingsConfig - { - Lang = "js", - Output = "bindings/winrt", - AdditionalWinmds = new() { "vendor/Foo.winmd", @"C:\abs\Bar.winmd" }, - }; - - _configService.Save(original); - var roundTrip = _configService.Load(); - - Assert.IsNotNull(roundTrip.JsBindings); - CollectionAssert.AreEqual( - _arr06, - roundTrip.JsBindings.AdditionalWinmds, - "additionalWinmds must round-trip declaration order intact"); - } - - // ------------------------------------------------------------------------- - // additionalRefs — same parsing rules as additionalWinmds, but flows into - // the codegen's --ref channel, not the winmd list file. Pairs with - // extraTypes for cherry-picking from a vendor winmd. - // ------------------------------------------------------------------------- - - [TestMethod] - public void Load_AdditionalRefs_ParsesRelativeAndAbsolutePaths() - { - File.WriteAllText(_configService.ConfigPath.FullName, """ - packages: - - name: Microsoft.WindowsAppSDK - version: 1.8.39 - jsBindings: - output: bindings/winrt - additionalRefs: - - vendor/BigVendor.winmd - - C:\shared\OtherCompany.SDK.winmd - extraTypes: - - namespace: BigVendor.Camera - classes: - - Lens - - Sensor - """); - - var cfg = _configService.Load(); - - Assert.IsNotNull(cfg.JsBindings); - CollectionAssert.AreEqual( - _arr05, - cfg.JsBindings.AdditionalRefs); - // Adjacent extraTypes block must still parse correctly when - // additionalRefs precedes it (regression guard for parser state). - Assert.AreEqual(1, cfg.JsBindings.ExtraTypes.Count); - Assert.AreEqual("BigVendor.Camera", cfg.JsBindings.ExtraTypes[0].Namespace); - CollectionAssert.AreEqual(_arr04, cfg.JsBindings.ExtraTypes[0].Classes); - } - - [TestMethod] - public void Load_AdditionalRefs_DedupesCaseInsensitive() - { - File.WriteAllText(_configService.ConfigPath.FullName, """ - packages: - - name: Microsoft.WindowsAppSDK - version: 1.8.39 - jsBindings: - output: bindings/winrt - additionalRefs: - - vendor/Foo.winmd - - VENDOR/FOO.WINMD - - vendor/Bar.winmd - """); - - var cfg = _configService.Load(); - - Assert.IsNotNull(cfg.JsBindings); - Assert.AreEqual(2, cfg.JsBindings.AdditionalRefs.Count, - "additionalRefs must dedupe case-insensitive (parser-level guard)"); - } - - [TestMethod] - public void SaveAndLoad_AdditionalRefs_RoundTrips() - { - var original = new WinappConfig(); - original.SetVersion("Microsoft.WindowsAppSDK", "1.8.39"); - original.JsBindings = new JsBindingsConfig - { - Lang = "js", - Output = "bindings/winrt", - AdditionalWinmds = new() { "vendor/Foo.winmd" }, - AdditionalRefs = new() { "vendor/BigVendor.winmd", @"C:\abs\OtherSdk.winmd" }, - ExtraTypes = - { - new JsBindingsExtraType - { - Namespace = "BigVendor.Camera", - Classes = { "Lens" }, - }, - }, - }; - - _configService.Save(original); - var roundTrip = _configService.Load(); - - Assert.IsNotNull(roundTrip.JsBindings); - // Both list fields must coexist and round-trip in their declaration order. - CollectionAssert.AreEqual( - _arr03, - roundTrip.JsBindings.AdditionalWinmds); - CollectionAssert.AreEqual( - _arr02, - roundTrip.JsBindings.AdditionalRefs); - // Extras-types adjacent block must also survive - Assert.AreEqual(1, roundTrip.JsBindings.ExtraTypes.Count); - Assert.AreEqual("BigVendor.Camera", roundTrip.JsBindings.ExtraTypes[0].Namespace); - } - - [TestMethod] - public void Load_NoAdditionalRefs_DefaultsToEmpty() - { - File.WriteAllText(_configService.ConfigPath.FullName, """ - packages: - - name: Microsoft.WindowsAppSDK - version: 1.8.39 - jsBindings: - output: bindings/winrt - """); - - var cfg = _configService.Load(); - - Assert.IsNotNull(cfg.JsBindings); - Assert.IsNotNull(cfg.JsBindings.AdditionalRefs); - Assert.AreEqual(0, cfg.JsBindings.AdditionalRefs.Count); - } - - // ------------------------------------------------------------------------- - // packages — preset slice (NuGet package IDs that the codegen run scopes - // to). Empty list = all installed packages participate. - // ------------------------------------------------------------------------- - - [TestMethod] - public void Load_Packages_ParsesAndDedupes() - { - File.WriteAllText(_configService.ConfigPath.FullName, """ - packages: - - name: Microsoft.WindowsAppSDK - version: 1.8.39 - jsBindings: - output: bindings/winrt - packages: - - Microsoft.WindowsAppSDK.AI - - Microsoft.WindowsAppSDK - - microsoft.windowsappsdk.ai - """); - - var cfg = _configService.Load(); - - Assert.IsNotNull(cfg.JsBindings); - CollectionAssert.AreEqual( - _arr01, - cfg.JsBindings.Packages, - "Packages list must dedupe case-insensitively while preserving the first-seen casing."); - } - - [TestMethod] - public void SaveAndLoad_Packages_RoundTrips() - { - var original = new WinappConfig(); - original.SetVersion("Microsoft.WindowsAppSDK", "1.8.39"); - original.JsBindings = new JsBindingsConfig - { - Lang = "js", - Output = "bindings/winrt", - Packages = new() { "Microsoft.WindowsAppSDK.AI" }, - }; - - _configService.Save(original); - var roundTrip = _configService.Load(); - - Assert.IsNotNull(roundTrip.JsBindings); - CollectionAssert.AreEqual( - _arr00, - roundTrip.JsBindings.Packages); - } - - [TestMethod] - public void Load_NoPackages_DefaultsToEmpty() - { - // Empty list (NOT null) — semantics is "all installed packages participate". - File.WriteAllText(_configService.ConfigPath.FullName, """ - packages: - - name: Microsoft.WindowsAppSDK - version: 1.8.39 - jsBindings: - output: bindings/winrt - """); - - var cfg = _configService.Load(); - - Assert.IsNotNull(cfg.JsBindings); - Assert.IsNotNull(cfg.JsBindings.Packages); - Assert.AreEqual(0, cfg.JsBindings.Packages.Count); - } - - // ------------------------------------------------------------------------- - // Save() preserves comments + unknown fields outside jsBindings: - // ------------------------------------------------------------------------- - - [TestMethod] - public void Save_PreservesCommentsAndUnknownFields_OutsideJsBindings() - { - // Round-trip test: the user has a yaml with comments, a custom - // top-level field winapp doesn't know about, and a jsBindings: block. - // Save() must preserve everything except the jsBindings: block itself. - var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); - var originalYaml = - "# Top of file comment — must survive\n" - + "\n" - + "packages:\n" - + " # inline comment near a package\n" - + " - name: Microsoft.WindowsAppSDK\n" - + " version: 1.8.39\n" - + " - name: Microsoft.Windows.CppWinRT\n" - + " version: 2.0.250303.1\n" - + "\n" - + "# A user-added top-level field winapp's model doesn't know about\n" - + "customField:\n" - + " enabled: true\n" - + " notes: should-survive\n" - + "\n" - + "jsBindings:\n" - + " output: old/output\n" - + " lang: js\n"; - File.WriteAllText(configPath, originalYaml); - - var loaded = _configService.Load(); - // Mutate just jsBindings.output (simulating what add jsbindings does). - loaded.JsBindings!.Output = "new/output"; - _configService.SaveJsBindingsOnly(loaded); - - var roundtripped = File.ReadAllText(configPath); - - // Comments preserved. - StringAssert.Contains(roundtripped, "# Top of file comment — must survive", - "Top-of-file comments must survive SaveJsBindingsOnly"); - StringAssert.Contains(roundtripped, "# inline comment near a package", - "Inline comments must survive SaveJsBindingsOnly"); - StringAssert.Contains(roundtripped, "# A user-added top-level field winapp's model doesn't know about", - "Comments above unknown fields must survive SaveJsBindingsOnly"); - - // Unknown top-level field preserved verbatim. - StringAssert.Contains(roundtripped, "customField:", - "Unknown top-level fields must survive SaveJsBindingsOnly"); - StringAssert.Contains(roundtripped, "enabled: true", - "Unknown fields' children must survive SaveJsBindingsOnly"); - StringAssert.Contains(roundtripped, "notes: should-survive", - "Unknown fields' children must survive SaveJsBindingsOnly"); - - // Original packages: untouched. - StringAssert.Contains(roundtripped, "Microsoft.WindowsAppSDK", "packages: must survive SaveJsBindingsOnly"); - StringAssert.Contains(roundtripped, "Microsoft.Windows.CppWinRT"); - StringAssert.Contains(roundtripped, "version: 1.8.39"); - - // jsBindings: was patched. - StringAssert.Contains(roundtripped, "new/output", - "jsBindings.output should reflect the in-memory mutation"); - Assert.IsFalse(roundtripped.Contains("old/output"), - "Old jsBindings.output should be gone"); - } - - [TestMethod] - public void SaveJsBindingsOnly_AppendsJsBindings_WhenAbsent() - { - // Yaml has packages: but no jsBindings: block. After in-memory - // injection + SaveJsBindingsOnly(), the new block should be appended. - var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); - var originalYaml = - "# pinning my packages\n" - + "packages:\n" - + " - name: Microsoft.WindowsAppSDK\n" - + " version: 1.8.39\n"; - File.WriteAllText(configPath, originalYaml); - - var loaded = _configService.Load(); - loaded.JsBindings = new WinApp.Cli.Models.JsBindingsConfig - { - Output = "src/bindings", - Lang = "js", - Packages = { "Microsoft.WindowsAppSDK.AI" }, - }; - _configService.SaveJsBindingsOnly(loaded); - - var roundtripped = File.ReadAllText(configPath); - StringAssert.Contains(roundtripped, "# pinning my packages", - "Existing comments must survive append"); - StringAssert.Contains(roundtripped, "jsBindings:", - "New jsBindings block must be appended"); - StringAssert.Contains(roundtripped, "src/bindings"); - StringAssert.Contains(roundtripped, "Microsoft.WindowsAppSDK.AI"); - } - - [TestMethod] - public void Save_PersistsPackageVersionChanges_OverwritingExistingFile() - { - // Regression guard for review #3 H1: ConfigService.Save() must persist - // ALL model state, not just jsBindings:. winapp update mutates pinned - // versions via SetVersion(...) then calls Save() — if Save() only - // patched jsBindings:, those version bumps would silently disappear. - var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); - File.WriteAllText(configPath, - "packages:\n" - + " - name: Microsoft.WindowsAppSDK\n" - + " version: 1.8.39\n" - + " - name: Microsoft.Windows.SDK.BuildTools\n" - + " version: 10.0.26100.6901\n"); - - var cfg = _configService.Load(); - // Simulate winapp update bumping versions. - cfg.SetVersion("Microsoft.WindowsAppSDK", "1.9.0-newer"); - cfg.SetVersion("Microsoft.Windows.SDK.BuildTools", "10.0.99999.9"); - _configService.Save(cfg); - - var roundtripped = File.ReadAllText(configPath); - StringAssert.Contains(roundtripped, "1.9.0-newer", - "winapp update's version bump must persist; previously the splice-only Save() silently dropped it."); - StringAssert.Contains(roundtripped, "10.0.99999.9"); - Assert.IsFalse(roundtripped.Contains("1.8.39"), - "Old version string must be gone after Save()"); - } - - [TestMethod] - public void Load_UnknownTopLevelFieldAfterJsBindings_IsNotAbsorbed() - { - // Regression for review #3 M5: an unknown zero-indent key after - // jsBindings: must not have its children parsed as JS-binding content. - var yaml = - "packages:\n" - + " - name: Microsoft.WindowsAppSDK\n" - + " version: 1.8.39\n" - + "jsBindings:\n" - + " output: bindings/winrt\n" - + " packages:\n" - + " - Microsoft.WindowsAppSDK.AI\n" - + "customField:\n" - + " output: should-not-clobber-jsbindings-output\n" - + " packages:\n" - + " - Should.Not.Appear.In.JsBindings.Packages\n"; - File.WriteAllText(_configService.ConfigPath.FullName, yaml); - - var loaded = _configService.Load(); - - Assert.IsNotNull(loaded.JsBindings); - Assert.AreEqual("bindings/winrt", loaded.JsBindings!.Output, - "Unknown top-level key must NOT overwrite jsBindings.output"); - CollectionAssert.AreEqual( - _arr00, - loaded.JsBindings.Packages.ToList(), - "Unknown top-level key's list children must NOT leak into jsBindings.packages"); - } - - [TestMethod] - public void SaveJsBindingsOnly_PreservesTopLevelCommentAfterJsBindingsBlock() - { - // Regression: a zero-indent `# comment` between jsBindings: and the - // next top-level key must survive the splice (it belongs to the next - // section, not jsBindings). - var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); - File.WriteAllText(configPath, - "packages:\n" - + " - name: Microsoft.WindowsAppSDK\n" - + " version: 1.8.39\n" - + "jsBindings:\n" - + " output: old/output\n" - + " lang: js\n" - + "\n" - + "# IMPORTANT comment about customField (must survive splice)\n" - + "customField:\n" - + " value: 42\n"); - - var loaded = _configService.Load(); - loaded.JsBindings!.Output = "new/output"; - _configService.SaveJsBindingsOnly(loaded); - - var roundtripped = File.ReadAllText(configPath); - StringAssert.Contains(roundtripped, "# IMPORTANT comment about customField", - "Zero-indent comment between jsBindings: and the next top-level key must survive splice."); - StringAssert.Contains(roundtripped, "customField:"); - StringAssert.Contains(roundtripped, "new/output"); - Assert.IsFalse(roundtripped.Contains("old/output")); - } - - // silent lossy-fallback in SaveJsBindingsOnly removed. - // When the read or splice fails on an EXISTING file, the call must - // throw rather than overwrite with a full serialization that strips - // comments and unknown fields. - [TestMethod] - public void SaveJsBindingsOnly_ExistingFileLocked_ThrowsRatherThanLossyOverwrite() - { - var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); - var originalYaml = - "# top-level user comment that must survive\n" - + "packages:\n" - + " - name: Microsoft.WindowsAppSDK\n" - + " version: 1.8.39\n" - + "customField: 42 # unknown YAML field\n"; - File.WriteAllText(configPath, originalYaml); - - var cfg = _configService.Load(); - cfg.JsBindings = new JsBindingsConfig { Output = "bindings/winrt", Lang = "js" }; - - // Hold an exclusive write lock that blocks File.ReadAllText. - using var blocker = new FileStream(configPath, FileMode.Open, FileAccess.Write, FileShare.None); - - var ex = Assert.ThrowsExactly( - () => _configService.SaveJsBindingsOnly(cfg)); - StringAssert.Contains(ex.Message, "preserving comments", - "Error must explain the lossy-write guard rather than silently corrupting the file."); - StringAssert.Contains(ex.Message, "winapp.yaml", - "Error must include the affected file path."); - - // Release the lock and verify the file on disk is the original - // (not a lossy full-serialization). - blocker.Dispose(); - var afterFailure = File.ReadAllText(configPath); - Assert.AreEqual(originalYaml, afterFailure, - "On failure, the file on disk must remain bit-identical to the original — " - + "no comments stripped, no unknown fields dropped."); - } - - [TestMethod] - public void SaveJsBindingsOnly_NewFile_WritesViaStringify() - { - // Regression guard: the new-file (ConfigPath.Exists == false) path - // still uses full serialization. There is nothing to preserve, so - // Stringify is the right behavior. - var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); - Assert.IsFalse(File.Exists(configPath), "Pre-condition: no existing config."); - - var cfg = new WinappConfig - { - Packages = { new PackagePin { Name = "Microsoft.WindowsAppSDK", Version = "1.8.39" } }, - JsBindings = new JsBindingsConfig { Output = "bindings/winrt", Lang = "js" }, - }; - - _configService.SaveJsBindingsOnly(cfg); - - Assert.IsTrue(File.Exists(configPath), "File must be created."); - var content = File.ReadAllText(configPath); - StringAssert.Contains(content, "jsBindings:"); - StringAssert.Contains(content, "output: bindings/winrt"); - } - - [TestMethod] - public void Load_ConfigPathIsSymlink_Throws() - { - // Plant a real winapp.yaml elsewhere, point ConfigPath at a - // symlink to it inside the workspace, and assert Load() refuses. - // Without this guard, a malicious workspace could redirect the - // editor (Save / SaveJsBindingsOnly) at any victim file the user - // has write access to. - var realDir = new DirectoryInfo( - Path.Combine(Path.GetTempPath(), $"ConfigSvcRealCfg_{Guid.NewGuid():N}")); - realDir.Create(); - try - { - var realCfg = Path.Combine(realDir.FullName, "real-winapp.yaml"); - File.WriteAllText(realCfg, "packages: []\n"); - - var linked = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); - try - { - File.CreateSymbolicLink(linked, realCfg); - } - catch (Exception ex) when (ex is UnauthorizedAccessException or IOException) - { - Assert.Inconclusive($"Could not create symlink: {ex.Message}"); - return; - } - - _configService.ConfigPath = new FileInfo(linked); - var ex2 = Assert.ThrowsExactly(() => _configService.Load()); - StringAssert.Contains(ex2.Message, "symbolic link", - "GuardConfigPath must explain why it refused the path"); - Assert.AreEqual("packages: []\n", File.ReadAllText(realCfg), - "Real config file must be untouched by the refused Load"); - } - finally - { - try { realDir.Delete(true); } catch { /* ignore */ } - } - } - - [TestMethod] - public void Save_ConfigDirAncestorIsJunction_Throws() - { - // Same threat at a directory ancestor: the parent of winapp.yaml - // is a junction. SaveJsBindingsOnly must refuse so user changes - // can't get redirected to the junction target. - var realDir = new DirectoryInfo( - Path.Combine(Path.GetTempPath(), $"ConfigSvcRealDir_{Guid.NewGuid():N}")); - realDir.Create(); - try - { - var junctionPath = Path.Combine(_tempDirectory.FullName, "nested"); - if (!TryCreateJunction(junctionPath, realDir.FullName)) - { - Assert.Inconclusive("Could not create a junction (CI may lack the privilege)."); - return; - } - - _configService.ConfigPath = new FileInfo( - Path.Combine(junctionPath, "winapp.yaml")); - File.WriteAllText(_configService.ConfigPath.FullName, "packages: []\n"); - - var ex2 = Assert.ThrowsExactly(() => - _configService.SaveJsBindingsOnly(new WinappConfig - { - JsBindings = new JsBindingsConfig { Lang = "js", Output = "bindings/winrt" }, - })); - StringAssert.Contains(ex2.Message, "symbolic link"); - } - finally - { - try { realDir.Delete(true); } catch { /* ignore */ } - } - } - - private static bool TryCreateJunction(string link, string target) - { - try - { - var psi = new System.Diagnostics.ProcessStartInfo - { - FileName = "cmd.exe", - Arguments = $"/c mklink /J \"{link}\" \"{target}\"", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - }; - using var p = System.Diagnostics.Process.Start(psi); - if (p is null) - { - return false; - } - p.WaitForExit(); - return p.ExitCode == 0 && Directory.Exists(link); - } - catch - { - return false; - } - } -} \ No newline at end of file diff --git a/src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenArgvTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenArgvTests.cs deleted file mode 100644 index e155bb7f..00000000 --- a/src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenArgvTests.cs +++ /dev/null @@ -1,349 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using WinApp.Cli.Helpers; -using WinApp.Cli.Models; -using WinApp.Cli.Services; - -namespace WinApp.Cli.Tests; - -// split of the historical DynWinrtCodegenServiceTests. -// Scope: argv construction (BuildBulkArgs / BuildExtraTypeArgs) + -// upstream input-collection helpers that feed the argv builders. -[TestClass] -public class DynWinrtCodegenArgvTests -{ - public TestContext TestContext { get; set; } = null!; - - private DirectoryInfo _temp = null!; - - [TestInitialize] - public void Init() - { - _temp = new DirectoryInfo(Path.Combine(Path.GetTempPath(), $"DynWinrtCodegenArgvTests_{Guid.NewGuid():N}")); - _temp.Create(); - } - - [TestCleanup] - public void Cleanup() - { - try { _temp.Delete(recursive: true); } catch { /* ignore */ } - } - - // ------------------------------------------------------------------------- - // CollectListedWinmds — dedup + Windows SDK appended at the end. - // ------------------------------------------------------------------------- - - [TestMethod] - public void CollectListedWinmds_DedupesAcrossSources() - { - var a = new FileInfo(Path.Combine(_temp.FullName, "A.winmd")); - var b = new FileInfo(Path.Combine(_temp.FullName, "B.winmd")); - var sdk = new FileInfo(Path.Combine(_temp.FullName, "Windows.winmd")); - var winmds = new[] { a, b }; - var userAdditional = new[] { b, a }; - - var result = DynWinrtCodegenService.CollectListedWinmds(winmds, userAdditional, sdk); - - Assert.AreEqual(3, result.Count, "Three unique winmds expected after dedup."); - Assert.AreEqual(a.FullName, result[0].FullName); - Assert.AreEqual(b.FullName, result[1].FullName); - Assert.AreEqual(sdk.FullName, result[2].FullName, "Windows SDK winmd must come last."); - } - - [TestMethod] - public void CollectListedWinmds_NullWindowsSdkWinmd_OmittedSilently() - { - var a = new FileInfo(Path.Combine(_temp.FullName, "A.winmd")); - var result = DynWinrtCodegenService.CollectListedWinmds(new[] { a }, userAdditional: null, windowsSdkWinmd: null); - Assert.AreEqual(1, result.Count); - } - - // ------------------------------------------------------------------------- - // CollectRefWinmds — additionalWinmds wins over additionalRefs. - // ------------------------------------------------------------------------- - - [TestMethod] - public void CollectRefWinmds_FileAlsoInRsp_DroppedFromRefs() - { - var shared = new FileInfo(Path.Combine(_temp.FullName, "Shared.winmd")); - var refOnly = new FileInfo(Path.Combine(_temp.FullName, "RefOnly.winmd")); - var list = new[] { shared }; - var refs = new[] { shared, refOnly }; - - var result = DynWinrtCodegenService.CollectRefWinmds(refs, list); - - Assert.AreEqual(1, result.Count); - Assert.AreEqual(refOnly.FullName, result[0].FullName, - "When a path appears in both additionalWinmds and additionalRefs, additionalWinmds wins so refs only keeps the unique entry."); - } - - [TestMethod] - public void CollectRefWinmds_NullOrEmpty_ReturnsEmpty() - { - var list = Array.Empty(); - Assert.AreEqual(0, DynWinrtCodegenService.CollectRefWinmds(null, list).Count); - Assert.AreEqual(0, DynWinrtCodegenService.CollectRefWinmds(Array.Empty(), list).Count); - } - - // ------------------------------------------------------------------------- - // ScopeUsedVersionsToBindingPackages — preset slicing primitive. - // ------------------------------------------------------------------------- - - [TestMethod] - public void ScopeUsedVersionsToBindingPackages_NullOrEmptyPackages_ReturnsAll() - { - var input = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["Microsoft.WindowsAppSDK"] = "1.8.39", - ["Microsoft.WindowsAppSDK.AI"] = "1.8.39", - }; - - var resultNull = JsBindingsWorkspaceService.ScopeUsedVersionsToBindingPackages(input, null); - Assert.AreEqual(2, resultNull.Count, "Null packages list = all packages participate (pre-preset default)."); - - var resultEmpty = JsBindingsWorkspaceService.ScopeUsedVersionsToBindingPackages(input, Array.Empty()); - Assert.AreEqual(2, resultEmpty.Count, "Empty packages list = all packages participate."); - } - - [TestMethod] - public void ScopeUsedVersionsToBindingPackages_FiltersDictionaryToAllowSet_CaseInsensitive() - { - var input = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["Microsoft.WindowsAppSDK"] = "1.8.39", - ["Microsoft.WindowsAppSDK.AI"] = "1.8.39", - ["Microsoft.Windows.SDK.NET.Ref"] = "10.0.26100.93", - }; - var preset = new[] { "microsoft.windowsappsdk.ai" }; - - var result = JsBindingsWorkspaceService.ScopeUsedVersionsToBindingPackages(input, preset); - - Assert.AreEqual(1, result.Count, "Only the preset-listed package should survive the scope."); - Assert.IsTrue(result.ContainsKey("Microsoft.WindowsAppSDK.AI")); - Assert.AreEqual("1.8.39", result["Microsoft.WindowsAppSDK.AI"]); - } - - // ------------------------------------------------------------------------- - // MergeRefWinmds — combines (package-derived ref-only winmds) with - // (user-supplied jsBindings.additionalRefs). Pure list-merge + dedup. - // ------------------------------------------------------------------------- - - [TestMethod] - public void MergeRefWinmds_BothEmpty_ReturnsEmpty() - { - var result = JsBindingsWorkspaceService.MergeRefWinmds(Array.Empty(), null); - Assert.AreEqual(0, result.Count); - var result2 = JsBindingsWorkspaceService.MergeRefWinmds(Array.Empty(), Array.Empty()); - Assert.AreEqual(0, result2.Count); - } - - [TestMethod] - public void MergeRefWinmds_PreservesInputOrder_FirstThenSecond() - { - var first = new[] - { - new FileInfo(Path.Combine(_temp.FullName, "pkg-A.winmd")), - new FileInfo(Path.Combine(_temp.FullName, "pkg-B.winmd")), - }; - var second = new[] - { - new FileInfo(Path.Combine(_temp.FullName, "user-X.winmd")), - new FileInfo(Path.Combine(_temp.FullName, "user-Y.winmd")), - }; - - var result = JsBindingsWorkspaceService.MergeRefWinmds(first, second); - - Assert.AreEqual(4, result.Count); - Assert.IsTrue(result[0].FullName.EndsWith("pkg-A.winmd", StringComparison.OrdinalIgnoreCase)); - Assert.IsTrue(result[1].FullName.EndsWith("pkg-B.winmd", StringComparison.OrdinalIgnoreCase)); - Assert.IsTrue(result[2].FullName.EndsWith("user-X.winmd", StringComparison.OrdinalIgnoreCase)); - Assert.IsTrue(result[3].FullName.EndsWith("user-Y.winmd", StringComparison.OrdinalIgnoreCase)); - } - - [TestMethod] - public void MergeRefWinmds_DedupesByFullName_CaseInsensitive() - { - var path = Path.Combine(_temp.FullName, "Foo.winmd"); - var first = new[] { new FileInfo(path) }; - var second = new[] - { - new FileInfo(path.ToUpperInvariant()), - new FileInfo(Path.Combine(_temp.FullName, "Bar.winmd")), - }; - - var result = JsBindingsWorkspaceService.MergeRefWinmds(first, second); - - Assert.AreEqual(2, result.Count, "Foo.winmd must appear once even though second list has a case-variant duplicate."); - } - - // ------------------------------------------------------------------------- - // PathSafety.IsNetworkPath — classify UNC / network paths so the - // additionalWinmds / additionalRefs / lockfile path probes can refuse - // to negotiate SMB with attacker-controlled hosts. - // ------------------------------------------------------------------------- - - [TestMethod] - [DataRow("\\\\server\\share\\file.winmd", true, "Plain UNC")] - [DataRow("//server/share/file.winmd", true, "Forward-slash UNC")] - [DataRow("\\\\attacker.example.com\\share\\evil.winmd", true, "Hostname UNC")] - [DataRow("\\\\?\\UNC\\server\\share\\file.winmd", true, "Long-path UNC")] - [DataRow("\\\\.\\UNC\\server\\share\\file.winmd", true, "Device-path UNC")] - [DataRow("\\\\?\\unc\\server\\share\\file.winmd", true, "Long-path UNC lowercase")] - [DataRow("C:\\Users\\me\\winmds\\Foo.winmd", false, "Local DOS path")] - [DataRow("C:/Users/me/winmds/Foo.winmd", false, "Local forward-slash DOS path")] - [DataRow("\\\\?\\C:\\Users\\me\\Foo.winmd", false, "Local long-path DOS")] - [DataRow("\\\\.\\C:\\Users\\me\\Foo.winmd", false, "Local device DOS")] - [DataRow("relative/file.winmd", false, "Relative path")] - [DataRow("", false, "Empty")] - public void IsNetworkPath_ClassifiesPathsCorrectly(string path, bool expected, string label) - { - Assert.AreEqual(expected, PathSafety.IsNetworkPath(path), - $"Path classification mismatch for {label}: {path}"); - } - - // ------------------------------------------------------------------------- - // BuildBulkArgs / BuildExtraTypeArgs — full argv construction. - // ------------------------------------------------------------------------- - - [TestMethod] - public void BuildBulkArgs_IncludesGenerateAndWinmdAndOutputAndLang() - { - var winmds = new[] - { - new FileInfo(Path.Combine(_temp.FullName, "A.winmd")), - new FileInfo(Path.Combine(_temp.FullName, "B.winmd")), - }; - var refs = new List(); - var config = new JsBindingsConfig { Lang = "js", Output = "bindings/winrt" }; - var args = DynWinrtCodegenService.BuildBulkArgs(Array.Empty(), winmds, _temp, config, refs); - - Assert.AreEqual("generate", args[0]); - CollectionAssert.Contains(args, "--winmd"); - Assert.IsTrue(args.Any(a => a.Contains("A.winmd") && a.Contains("B.winmd") && a.Contains(';')), - "Multiple winmds must be semicolon-joined under --winmd."); - CollectionAssert.Contains(args, "--output"); - CollectionAssert.Contains(args, "--lang"); - CollectionAssert.Contains(args, "js"); - Assert.IsFalse(args.Contains("--ref"), "No --ref when ref list is empty."); - Assert.IsFalse(args.Contains("--pyi"), "No --pyi unless lang=py."); - } - - [TestMethod] - public void BuildBulkArgs_WithRefs_IncludesRefFlag() - { - var winmds = new[] { new FileInfo(Path.Combine(_temp.FullName, "A.winmd")) }; - var refs = new List - { - new(Path.Combine(_temp.FullName, "R1.winmd")), - new(Path.Combine(_temp.FullName, "R2.winmd")), - }; - var config = new JsBindingsConfig { Lang = "js" }; - var args = DynWinrtCodegenService.BuildBulkArgs(Array.Empty(), winmds, _temp, config, refs); - - var refIdx = args.IndexOf("--ref"); - Assert.IsTrue(refIdx >= 0, "Expected --ref flag."); - Assert.IsTrue(args[refIdx + 1].Contains("R1.winmd") && args[refIdx + 1].Contains("R2.winmd") - && args[refIdx + 1].Contains(';'), - $"Ref winmds must be semicolon-joined. Got: {args[refIdx + 1]}"); - } - - [TestMethod] - public void BuildBulkArgs_PyLang_AddsPyiFlag() - { - var winmds = new[] { new FileInfo(Path.Combine(_temp.FullName, "A.winmd")) }; - var config = new JsBindingsConfig { Lang = "py" }; - var args = DynWinrtCodegenService.BuildBulkArgs(Array.Empty(), winmds, _temp, config, new List()); - - CollectionAssert.Contains(args, "--pyi", "Python lang must emit --pyi."); - CollectionAssert.Contains(args, "py"); - } - - [TestMethod] - public void BuildBulkArgs_PrefixArgsPreserved() - { - var winmds = new[] { new FileInfo(Path.Combine(_temp.FullName, "A.winmd")) }; - var prefix = new[] { "C:\\Node\\node.exe", "cli.js" }; - var config = new JsBindingsConfig { Lang = "js" }; - var args = DynWinrtCodegenService.BuildBulkArgs(prefix, winmds, _temp, config, new List()); - - Assert.AreEqual("C:\\Node\\node.exe", args[0]); - Assert.AreEqual("cli.js", args[1]); - Assert.AreEqual("generate", args[2]); - } - - [TestMethod] - public void BuildExtraTypeArgs_IncludesNamespaceAndClassFlags() - { - var winmds = new[] { new FileInfo(Path.Combine(_temp.FullName, "Windows.winmd")) }; - var extra = new JsBindingsExtraType - { - Namespace = "Windows.Foundation", - Classes = { "Uri", "Calendar" }, - }; - var config = new JsBindingsConfig { Lang = "js" }; - var args = DynWinrtCodegenService.BuildExtraTypeArgs( - Array.Empty(), winmds, _temp, config, new List(), extra); - - Assert.AreEqual("generate", args[0]); - var nsIdx = args.IndexOf("--namespace"); - Assert.IsTrue(nsIdx >= 0); - Assert.AreEqual("Windows.Foundation", args[nsIdx + 1]); - var classIdx = args.IndexOf("--class-name"); - Assert.IsTrue(classIdx >= 0); - Assert.AreEqual("Uri,Calendar", args[classIdx + 1], - "Classes must be comma-joined."); - } - - // extraTypes-only cherry-pick workflow must produce a - // valid argv without an empty --winmd flag. When the user supplies - // refs + extraTypes alone (no bulk emit set), BuildExtraTypeArgs must - // omit --winmd entirely so codegen doesn't see `--winmd ""`. - - [TestMethod] - public void BuildExtraTypeArgs_EmptyEmitWinmds_OmitsWinmdFlag() - { - var refs = new List - { - new(Path.Combine(_temp.FullName, "Vendor.SDK.winmd")), - }; - var extra = new JsBindingsExtraType - { - Namespace = "Vendor.SDK.Camera", - Classes = { "Lens" }, - }; - var config = new JsBindingsConfig { Lang = "js" }; - - var args = DynWinrtCodegenService.BuildExtraTypeArgs( - Array.Empty(), Array.Empty(), _temp, config, refs, extra); - - Assert.IsFalse(args.Contains("--winmd"), - "When emit winmds are empty, --winmd must be omitted entirely (no empty arg)."); - CollectionAssert.Contains(args, "--ref", - "extraTypes-only flow still passes --ref for type resolution."); - CollectionAssert.Contains(args, "--namespace"); - CollectionAssert.Contains(args, "--class-name"); - CollectionAssert.Contains(args, "--lang"); - } - - [TestMethod] - public void BuildExtraTypeArgs_NonEmptyEmitWinmds_IncludesWinmdFlag() - { - // Regression guard: ensure the M2 fix didn't accidentally drop - // --winmd for the normal bulk + extraType combo. - var winmds = new[] { new FileInfo(Path.Combine(_temp.FullName, "Windows.winmd")) }; - var extra = new JsBindingsExtraType - { - Namespace = "Windows.Foundation", - Classes = { "Uri" }, - }; - var config = new JsBindingsConfig { Lang = "js" }; - - var args = DynWinrtCodegenService.BuildExtraTypeArgs( - Array.Empty(), winmds, _temp, config, new List(), extra); - - var winmdIdx = args.IndexOf("--winmd"); - Assert.IsTrue(winmdIdx >= 0, "Non-empty emit winmds must include --winmd."); - Assert.IsTrue(args[winmdIdx + 1].EndsWith("Windows.winmd", StringComparison.OrdinalIgnoreCase)); - } -} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenInvocationTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenInvocationTests.cs deleted file mode 100644 index 6b13f585..00000000 --- a/src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenInvocationTests.cs +++ /dev/null @@ -1,432 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using System.Runtime.InteropServices; -using WinApp.Cli.Services; - -namespace WinApp.Cli.Tests; - -// split of the historical DynWinrtCodegenServiceTests. -// Scope: ResolveExecutableOnPath / ResolveCodegenInvocation / SpawnCodegen. -[TestClass] -[DoNotParallelize] // CWD/PATH/PATHEXT hijack tests mutate process-wide state. -public class DynWinrtCodegenInvocationTests -{ - public TestContext TestContext { get; set; } = null!; - - private DirectoryInfo _temp = null!; - - [TestInitialize] - public void Init() - { - _temp = new DirectoryInfo(Path.Combine(Path.GetTempPath(), $"DynWinrtCodegenInvocationTests_{Guid.NewGuid():N}")); - _temp.Create(); - } - - [TestCleanup] - public void Cleanup() - { - try { _temp.Delete(recursive: true); } catch { /* ignore */ } - } - - // ------------------------------------------------------------------------- - // ResolveExecutableOnPath — PATH lookup must skip CWD-equivalent entries - // to prevent search-order hijack. - // ------------------------------------------------------------------------- - - [TestMethod] - public void ResolveExecutableOnPath_AbsolutePathIn_PassedThroughWhenExists() - { - var f = new FileInfo(Path.Combine(_temp.FullName, "tool.exe")); - File.WriteAllText(f.FullName, ""); - var resolved = DynWinrtCodegenService.ResolveExecutableOnPath(f.FullName); - Assert.AreEqual(f.FullName, resolved); - } - - [TestMethod] - public void ResolveExecutableOnPath_NonExistent_ReturnsNull() - { - Assert.IsNull(DynWinrtCodegenService.ResolveExecutableOnPath("this-tool-does-not-exist-anywhere")); - } - - [TestMethod] - public void ResolveExecutableOnPath_EmptyInput_ReturnsNull() - { - Assert.IsNull(DynWinrtCodegenService.ResolveExecutableOnPath("")); - Assert.IsNull(DynWinrtCodegenService.ResolveExecutableOnPath(" ")); - } - - [TestMethod] - public void ResolveExecutableOnPath_SkipsLiteralDotAndEmptyPathEntries() - { - var decoy = new DirectoryInfo(Path.Combine(_temp.FullName, "decoy-cwd")); - var safe = new DirectoryInfo(Path.Combine(_temp.FullName, "safe-bin")); - decoy.Create(); - safe.Create(); - var decoyNode = Path.Combine(decoy.FullName, "node.exe"); - var safeNode = Path.Combine(safe.FullName, "node.exe"); - File.WriteAllText(decoyNode, "DECOY"); - File.WriteAllText(safeNode, "SAFE"); - - var prevCwd = Directory.GetCurrentDirectory(); - var prevPath = Environment.GetEnvironmentVariable("PATH"); - var prevExt = Environment.GetEnvironmentVariable("PATHEXT"); - try - { - Directory.SetCurrentDirectory(decoy.FullName); - Environment.SetEnvironmentVariable( - "PATH", - $".{Path.PathSeparator}{Path.PathSeparator}{safe.FullName}"); - Environment.SetEnvironmentVariable("PATHEXT", ".COM;.EXE;.BAT;.CMD"); - - var resolved = DynWinrtCodegenService.ResolveExecutableOnPath("node"); - - Assert.IsNotNull(resolved); - Assert.AreEqual( - Path.GetFullPath(safeNode).ToLowerInvariant(), - Path.GetFullPath(resolved!).ToLowerInvariant(), - $"Expected safe PATH dir to win; got {resolved}"); - } - finally - { - Environment.SetEnvironmentVariable("PATH", prevPath); - Environment.SetEnvironmentVariable("PATHEXT", prevExt); - Directory.SetCurrentDirectory(prevCwd); - } - } - - [TestMethod] - public void ResolveExecutableOnPath_SkipsAbsolutePathEntryThatEqualsCwd() - { - var decoy = new DirectoryInfo(Path.Combine(_temp.FullName, "abs-cwd")); - var safe = new DirectoryInfo(Path.Combine(_temp.FullName, "safe-bin2")); - decoy.Create(); - safe.Create(); - File.WriteAllText(Path.Combine(decoy.FullName, "node.exe"), "DECOY"); - var safeNode = Path.Combine(safe.FullName, "node.exe"); - File.WriteAllText(safeNode, "SAFE"); - - var prevCwd = Directory.GetCurrentDirectory(); - var prevPath = Environment.GetEnvironmentVariable("PATH"); - var prevExt = Environment.GetEnvironmentVariable("PATHEXT"); - try - { - Directory.SetCurrentDirectory(decoy.FullName); - Environment.SetEnvironmentVariable( - "PATH", - $"{decoy.FullName}{Path.PathSeparator}{safe.FullName}"); - Environment.SetEnvironmentVariable("PATHEXT", ".COM;.EXE;.BAT;.CMD"); - - var resolved = DynWinrtCodegenService.ResolveExecutableOnPath("node"); - - Assert.IsNotNull(resolved); - Assert.AreEqual( - Path.GetFullPath(safeNode).ToLowerInvariant(), - Path.GetFullPath(resolved!).ToLowerInvariant(), - "Absolute PATH entry equal to CWD must be skipped to prevent local hijack."); - } - finally - { - Environment.SetEnvironmentVariable("PATH", prevPath); - Environment.SetEnvironmentVariable("PATHEXT", prevExt); - Directory.SetCurrentDirectory(prevCwd); - } - } - - [TestMethod] - public void ResolveExecutableOnPath_HonorsPathExt() - { - var safe = new DirectoryInfo(Path.Combine(_temp.FullName, "safe-cmd")); - safe.Create(); - var cmd = Path.Combine(safe.FullName, "node.cmd"); - File.WriteAllText(cmd, "@echo"); - - var prevPath = Environment.GetEnvironmentVariable("PATH"); - var prevExt = Environment.GetEnvironmentVariable("PATHEXT"); - try - { - Environment.SetEnvironmentVariable("PATH", safe.FullName); - Environment.SetEnvironmentVariable("PATHEXT", ".COM;.EXE;.BAT;.CMD"); - - var resolved = DynWinrtCodegenService.ResolveExecutableOnPath("node"); - - Assert.IsNotNull(resolved); - Assert.AreEqual( - Path.GetFullPath(cmd).ToLowerInvariant(), - Path.GetFullPath(resolved!).ToLowerInvariant()); - } - finally - { - Environment.SetEnvironmentVariable("PATH", prevPath); - Environment.SetEnvironmentVariable("PATHEXT", prevExt); - } - } - - // nativeOnly mode must reject .bat/.cmd/.ps1, which dispatch - // through cmd.exe / pwsh and would re-parse user-derived args. - - [TestMethod] - public void ResolveExecutableOnPath_NativeOnly_RejectsBatAndCmd() - { - var safe = new DirectoryInfo(Path.Combine(_temp.FullName, "safe-native")); - safe.Create(); - // Only a node.cmd is available — nativeOnly must refuse. - var cmd = Path.Combine(safe.FullName, "node.cmd"); - File.WriteAllText(cmd, "@echo"); - - var prevPath = Environment.GetEnvironmentVariable("PATH"); - var prevExt = Environment.GetEnvironmentVariable("PATHEXT"); - try - { - Environment.SetEnvironmentVariable("PATH", safe.FullName); - Environment.SetEnvironmentVariable("PATHEXT", ".COM;.EXE;.BAT;.CMD"); - - var nonStrict = DynWinrtCodegenService.ResolveExecutableOnPath("node"); - Assert.IsNotNull(nonStrict, "Default mode still finds the .cmd via PATHEXT."); - - var nativeOnly = DynWinrtCodegenService.ResolveExecutableOnPath("node", nativeOnly: true); - Assert.IsNull(nativeOnly, "Native-only must skip .cmd to prevent cmd.exe arg re-parsing."); - } - finally - { - Environment.SetEnvironmentVariable("PATH", prevPath); - Environment.SetEnvironmentVariable("PATHEXT", prevExt); - } - } - - [TestMethod] - public void ResolveExecutableOnPath_NativeOnly_BareNameWithCmdExtension_Rejected() - { - // PATH entry contains a literal `node.cmd`; the bare-match path - // (which short-circuits PATHEXT) must still honor nativeOnly. - var safe = new DirectoryInfo(Path.Combine(_temp.FullName, "bare-cmd")); - safe.Create(); - var cmd = Path.Combine(safe.FullName, "node.cmd"); - File.WriteAllText(cmd, "@echo"); - - var prevPath = Environment.GetEnvironmentVariable("PATH"); - var prevExt = Environment.GetEnvironmentVariable("PATHEXT"); - try - { - Environment.SetEnvironmentVariable("PATH", safe.FullName); - Environment.SetEnvironmentVariable("PATHEXT", ".COM;.EXE;.BAT;.CMD"); - - // Passing "node.cmd" as the bare command: bare-match would find - // it, but nativeOnly rejects the .cmd extension. - var resolved = DynWinrtCodegenService.ResolveExecutableOnPath("node.cmd", nativeOnly: true); - Assert.IsNull(resolved, "nativeOnly bare-match must reject .cmd."); - } - finally - { - Environment.SetEnvironmentVariable("PATH", prevPath); - Environment.SetEnvironmentVariable("PATHEXT", prevExt); - } - } - - // ------------------------------------------------------------------------- - // ResolveCodegenInvocation — wrapper-bundled is the only trusted source. - // ------------------------------------------------------------------------- - - // Helper for arranging a wrapper layout under _temp. - private static string Arch => RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "arm64" : "x64"; - - private static FileInfo PlantCodegenExe(DirectoryInfo root) - { - var packageDir = new DirectoryInfo(Path.Combine( - root.FullName, "node_modules", "@microsoft", "dynwinrt-codegen")); - var binDir = new DirectoryInfo(Path.Combine(packageDir.FullName, "bin", Arch)); - binDir.Create(); - var exe = new FileInfo(Path.Combine(binDir.FullName, "dynwinrt-codegen.exe")); - File.WriteAllText(exe.FullName, ""); - return exe; - } - - [TestMethod] - public void ResolveCodegenInvocation_DirectExePreferred() - { - var packageDir = new DirectoryInfo(Path.Combine(_temp.FullName, "node_modules", "@microsoft", "dynwinrt-codegen")); - var arch = RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "arm64" : "x64"; - var binDir = new DirectoryInfo(Path.Combine(packageDir.FullName, "bin", arch)); - binDir.Create(); - var exe = new FileInfo(Path.Combine(binDir.FullName, "dynwinrt-codegen.exe")); - File.WriteAllBytes(exe.FullName, Array.Empty()); - File.WriteAllText(Path.Combine(packageDir.FullName, "cli.js"), "// stub"); - - var (resolved, args) = DynWinrtCodegenService.ResolveCodegenInvocationCore(wrapperDir: _temp); - - Assert.AreEqual(exe.FullName, resolved, "Direct .exe must win over cli.js fallback"); - Assert.AreEqual(0, args.Count, "Direct .exe call passes no prefix args"); - } - - [TestMethod] - public void ResolveCodegenInvocation_CliJsFallback_UsesQualifiedNodePath() - { - var packageDir = new DirectoryInfo(Path.Combine(_temp.FullName, "node_modules", "@microsoft", "dynwinrt-codegen")); - packageDir.Create(); - var cli = new FileInfo(Path.Combine(packageDir.FullName, "cli.js")); - File.WriteAllText(cli.FullName, "// stub"); - - // The fallback uses nativeOnly=true — only finds node.exe/.com. - var resolvedNode = DynWinrtCodegenService.ResolveExecutableOnPath("node", nativeOnly: true); - if (resolvedNode is null) - { - Assert.ThrowsExactly( - () => DynWinrtCodegenService.ResolveCodegenInvocationCore(wrapperDir: _temp), - "Without a native node executable, the fallback must refuse."); - return; - } - - var (exe, args) = DynWinrtCodegenService.ResolveCodegenInvocationCore(wrapperDir: _temp); - - Assert.AreEqual(resolvedNode, exe, - "Node executable must be the fully-resolved PATH lookup."); - Assert.IsTrue(Path.IsPathRooted(exe), - "Spawned executable path must be absolute to prevent CWD-search hijacks."); - var ext = Path.GetExtension(exe); - Assert.IsTrue( - ext.Equals(".exe", StringComparison.OrdinalIgnoreCase) - || ext.Equals(".com", StringComparison.OrdinalIgnoreCase), - $"Fallback must spawn a native node executable (.exe/.com), got: {ext}"); - Assert.AreEqual(1, args.Count); - Assert.AreEqual(cli.FullName, args[0]); - } - - [TestMethod] - public void ResolveCodegenInvocation_NothingFound_ThrowsActionableError() - { - // No wrapper install — error must point at the npm/yarn classic install. - var ex = Assert.ThrowsExactly( - () => DynWinrtCodegenService.ResolveCodegenInvocationCore( - wrapperDir: _temp.CreateSubdirectory("empty-wrapper"))); - - StringAssert.Contains(ex.Message, "@microsoft/dynwinrt-codegen"); - StringAssert.Contains(ex.Message, "@microsoft/winappcli"); - } - - [TestMethod] - public void ResolveCodegenInvocation_NullWrapperDir_ThrowsWithReinstallHint() - { - // wrapperDir is null on `dotnet run` and any host where Environment.ProcessPath - // is empty. The error must skip the per-dir search entirely and tell the user - // to reinstall the npm package rather than echoing .NET internals. - var ex = Assert.ThrowsExactly( - () => DynWinrtCodegenService.ResolveCodegenInvocationCore(wrapperDir: null)); - - StringAssert.Contains(ex.Message, "@microsoft/dynwinrt-codegen"); - StringAssert.Contains(ex.Message, "winapp install directory could not be determined"); - StringAssert.Contains(ex.Message, "reinstalling @microsoft/winappcli"); - } - - [TestMethod] - public void ResolveCodegenInvocation_UpwardLookup_FindsHoistedPackage() - { - // Hoisted layout reachable by walking up from wrapperDir — npm/yarn-classic happy path. - var repoRoot = _temp; - var nestedWrapper = repoRoot.CreateSubdirectory("apps").CreateSubdirectory("electron-app"); - - var packageDir = new DirectoryInfo(Path.Combine( - repoRoot.FullName, "node_modules", "@microsoft", "dynwinrt-codegen")); - packageDir.Create(); - var exe = new FileInfo(Path.Combine(packageDir.FullName, "bin", Arch, "dynwinrt-codegen.exe")); - exe.Directory!.Create(); - File.WriteAllText(exe.FullName, "stub"); - - var (resolved, args) = DynWinrtCodegenService.ResolveCodegenInvocationCore(wrapperDir: nestedWrapper); - - Assert.AreEqual(exe.FullName, resolved, - "Resolver must walk upward from the nested wrapper dir to find the hoisted codegen."); - Assert.AreEqual(0, args.Count); - } - - // ------------------------------------------------------------------------- - // SpawnCodegen — cancellation kills child process tree promptly. - // ------------------------------------------------------------------------- - - [TestMethod] - public async Task SpawnCodegen_CancellationKillsLongRunningChild_WithoutHang() - { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - Assert.Inconclusive("Windows-only test for ProcessTree kill."); - return; - } - - var cmd = DynWinrtCodegenService.ResolveExecutableOnPath("cmd"); - Assert.IsNotNull(cmd); - - var psi = new System.Diagnostics.ProcessStartInfo - { - FileName = cmd!, - ArgumentList = { "/c", "ping", "-n", "60", "127.0.0.1" }, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - }; - - using var cts = new CancellationTokenSource(); - using var p = System.Diagnostics.Process.Start(psi)!; - Assert.IsFalse(p.HasExited, "Child should start running."); - - _ = Task.Run(async () => { await Task.Delay(150); cts.Cancel(); }); - - var sw = System.Diagnostics.Stopwatch.StartNew(); - var caught = false; - try - { - await p.WaitForExitAsync(cts.Token); - } - catch (OperationCanceledException) - { - caught = true; - try - { - if (!p.HasExited) - { - p.Kill(entireProcessTree: true); - using var killCts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - await p.WaitForExitAsync(killCts.Token); - } - } - catch { /* best-effort */ } - } - sw.Stop(); - - Assert.IsTrue(caught, "Cancellation must surface OperationCanceledException."); - Assert.IsTrue(p.HasExited, "Child must be dead after cancel-and-kill."); - Assert.IsTrue(sw.ElapsedMilliseconds < 5_000, - $"Cancel+kill should complete fast; took {sw.ElapsedMilliseconds}ms."); - } - - // ------------------------------------------------------------------------- - // M6 — workspace-local codegen install must NOT be trusted as a fallback. - // The resolver searches up from the wrapper dir ONLY; anything planted - // under the user workspace must NOT short-circuit the wrapper-bundled - // requirement (this is the post-r3 security model). - // ------------------------------------------------------------------------- - - [TestMethod] - public void ResolveCodegenInvocation_WorkspaceLocalInstall_NotTrustedWhenWrapperEmpty() - { - // Plant a fully-formed codegen exe under a SIBLING dir of the wrapper - // (think: user workspace at `_temp/workspace/...`, wrapper at - // `_temp/empty-wrapper/`). The resolver must refuse — workspace-local - // installs no longer count as a fallback. - var workspaceRoot = _temp.CreateSubdirectory("workspace"); - var workspaceCodegen = PlantCodegenExe(workspaceRoot); - Assert.IsTrue(workspaceCodegen.Exists, "fixture sanity"); - - var emptyWrapper = _temp.CreateSubdirectory("empty-wrapper"); - - // The wrapper dir (and its ancestor chain) does NOT contain the - // codegen install; the workspace install must NOT rescue this. - var ex = Assert.ThrowsExactly( - () => DynWinrtCodegenService.ResolveCodegenInvocationCore(wrapperDir: emptyWrapper)); - - StringAssert.Contains(ex.Message, "@microsoft/dynwinrt-codegen", - "Refusal message must explain what was missing."); - // The error must not point at the workspace plant. - Assert.IsFalse(ex.Message.Contains(workspaceCodegen.FullName), - "Resolver must not have considered the workspace-local install."); - } -} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenOutputSafetyTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenOutputSafetyTests.cs deleted file mode 100644 index 701e277d..00000000 --- a/src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenOutputSafetyTests.cs +++ /dev/null @@ -1,250 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using System.Runtime.InteropServices; -using WinApp.Cli.Services; - -namespace WinApp.Cli.Tests; - -// split of the historical DynWinrtCodegenServiceTests. -// Scope: ResolveOutputDir / WipeOutputDirSafely / WriteManagedMarker — -// the "do not destroy user files" safety net. -[TestClass] -public class DynWinrtCodegenOutputSafetyTests -{ - public TestContext TestContext { get; set; } = null!; - - private DirectoryInfo _temp = null!; - - [TestInitialize] - public void Init() - { - _temp = new DirectoryInfo(Path.Combine(Path.GetTempPath(), $"DynWinrtCodegenOutputSafetyTests_{Guid.NewGuid():N}")); - _temp.Create(); - } - - [TestCleanup] - public void Cleanup() - { - try { _temp.Delete(recursive: true); } catch { /* ignore */ } - } - - // ------------------------------------------------------------------------- - // ResolveOutputDir — purely lexical, must not touch disk. - // ------------------------------------------------------------------------- - - [TestMethod] - public void ResolveOutputDir_RelativePath_ResolvedAgainstWorkspace() - { - var dir = DynWinrtCodegenService.ResolveOutputDir(_temp, "bindings/winrt"); - StringAssert.StartsWith(dir.FullName, _temp.FullName); - StringAssert.EndsWith(dir.FullName, Path.Combine("bindings", "winrt")); - } - - [TestMethod] - public void ResolveOutputDir_AbsolutePath_InsideWorkspace_Honored() - { - var abs = Path.Combine(_temp.FullName, "abs", "out"); - var dir = DynWinrtCodegenService.ResolveOutputDir(_temp, abs); - Assert.AreEqual(Path.GetFullPath(abs), dir.FullName); - } - - [TestMethod] - public void ResolveOutputDir_RejectsAbsolutePathOutsideWorkspace() - { - var ex = Assert.ThrowsExactly( - () => DynWinrtCodegenService.ResolveOutputDir(_temp, @"C:\some\other\place")); - StringAssert.Contains(ex.Message, "outside the workspace"); - } - - [TestMethod] - public void ResolveOutputDir_RejectsParentEscape() - { - var ex = Assert.ThrowsExactly( - () => DynWinrtCodegenService.ResolveOutputDir(_temp, "../escape")); - StringAssert.Contains(ex.Message, "outside the workspace"); - } - - [TestMethod] - public void ResolveOutputDir_RejectsWorkspaceRootItself() - { - var ex = Assert.ThrowsExactly( - () => DynWinrtCodegenService.ResolveOutputDir(_temp, _temp.FullName)); - StringAssert.Contains(ex.Message, "outside the workspace"); - } - - // ------------------------------------------------------------------------- - // WipeOutputDirSafely — marker presence is the single safety gate. - // ------------------------------------------------------------------------- - - [TestMethod] - public void WipeOutputDirSafely_NonExistentDir_NoOp() - { - var missing = new DirectoryInfo(Path.Combine(_temp.FullName, "missing")); - DynWinrtCodegenService.WipeOutputDirSafely(missing); - Assert.IsFalse(missing.Exists); - } - - [TestMethod] - public void WipeOutputDirSafely_EmptyDir_NoOpAndPreserved() - { - var empty = new DirectoryInfo(Path.Combine(_temp.FullName, "empty")); - empty.Create(); - DynWinrtCodegenService.WipeOutputDirSafely(empty); - empty.Refresh(); - Assert.IsTrue(empty.Exists); - } - - [TestMethod] - public void WipeOutputDirSafely_NonEmptyWithoutMarker_Throws() - { - var dir = new DirectoryInfo(Path.Combine(_temp.FullName, "user-files")); - dir.Create(); - File.WriteAllText(Path.Combine(dir.FullName, "user.ts"), "// user code"); - - var ex = Assert.ThrowsExactly( - () => DynWinrtCodegenService.WipeOutputDirSafely(dir)); - StringAssert.Contains(ex.Message, DynWinrtCodegenService.ManagedMarkerFileName); - StringAssert.Contains(ex.Message, "Refusing to wipe"); - - Assert.IsTrue(File.Exists(Path.Combine(dir.FullName, "user.ts")), - "User file must be preserved when wipe is refused."); - } - - [TestMethod] - public void WipeOutputDirSafely_NonEmptyWithMarker_DeletesAllChildren() - { - var dir = new DirectoryInfo(Path.Combine(_temp.FullName, "managed")); - dir.Create(); - File.WriteAllText(Path.Combine(dir.FullName, DynWinrtCodegenService.ManagedMarkerFileName), "marker"); - File.WriteAllText(Path.Combine(dir.FullName, "Uri.js"), "// generated"); - var subdir = new DirectoryInfo(Path.Combine(dir.FullName, "sub")); - subdir.Create(); - File.WriteAllText(Path.Combine(subdir.FullName, "Foo.js"), "// generated"); - - DynWinrtCodegenService.WipeOutputDirSafely(dir); - - dir.Refresh(); - Assert.IsTrue(dir.Exists, "Wipe deletes children but preserves the directory itself."); - Assert.AreEqual(0, dir.EnumerateFileSystemInfos().Count(), - "Marker, generated files, and subdirectories must all be removed so the next run starts clean."); - } - - // ------------------------------------------------------------------------- - // WriteManagedMarker — file content is debug-only; presence is the contract. - // ------------------------------------------------------------------------- - - [TestMethod] - public void WriteManagedMarker_CreatesFileNamedDynwinrtManaged() - { - var dir = new DirectoryInfo(Path.Combine(_temp.FullName, "out")); - dir.Create(); - - DynWinrtCodegenService.WriteManagedMarker(dir); - - var marker = new FileInfo(Path.Combine(dir.FullName, DynWinrtCodegenService.ManagedMarkerFileName)); - Assert.IsTrue(marker.Exists); - var body = File.ReadAllText(marker.FullName); - StringAssert.Contains(body, "generated_at:"); - } - - // ------------------------------------------------------------------------- - // Reparse-point (junction / symlink) rejection. Requires Windows + admin - // or developer-mode for symlink creation; tests skip otherwise. - // ------------------------------------------------------------------------- - - internal static bool TryCreateJunction(string linkPath, string targetPath, out string? skipReason) - { - skipReason = null; - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - skipReason = "Junctions are a Windows-only construct."; - return false; - } - try - { - var psi = new System.Diagnostics.ProcessStartInfo("cmd.exe", $"/c mklink /J \"{linkPath}\" \"{targetPath}\"") - { - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - }; - using var p = System.Diagnostics.Process.Start(psi); - if (p is null) - { - skipReason = "Could not spawn cmd.exe to create junction."; - return false; - } - p.WaitForExit(10_000); - if (p.ExitCode != 0) - { - skipReason = $"mklink failed (exit {p.ExitCode}): {p.StandardError.ReadToEnd().Trim()}"; - return false; - } - return Directory.Exists(linkPath); - } - catch (Exception ex) - { - skipReason = $"Junction creation threw: {ex.Message}"; - return false; - } - } - - [TestMethod] - public void WipeOutputDirSafely_RejectsReparsePointAsOutputDir() - { - var outsideTarget = new DirectoryInfo(Path.Combine(_temp.FullName, "outside")); - outsideTarget.Create(); - var preciousFile = Path.Combine(outsideTarget.FullName, "precious.txt"); - File.WriteAllText(preciousFile, "DO NOT DELETE"); - File.WriteAllText(Path.Combine(outsideTarget.FullName, DynWinrtCodegenService.ManagedMarkerFileName), - "# fake marker — would normally authorise wipe"); - - var workspace = new DirectoryInfo(Path.Combine(_temp.FullName, "ws")); - workspace.Create(); - var junction = Path.Combine(workspace.FullName, "bindings"); - - if (!TryCreateJunction(junction, outsideTarget.FullName, out var skip)) - { - Assert.Inconclusive(skip ?? "Junction creation unavailable in this environment."); - return; - } - - var outputDir = new DirectoryInfo(junction); - var threw = false; - try - { - DynWinrtCodegenService.WipeOutputDirSafely(outputDir); - } - catch (InvalidOperationException) - { - threw = true; - } - - Assert.IsTrue(threw, "Wipe must refuse a reparse-point output dir."); - Assert.IsTrue(File.Exists(preciousFile), - "Precious file behind the junction must remain untouched."); - } - - [TestMethod] - public void ResolveOutputDir_RejectsAncestorReparsePoint() - { - var outside = new DirectoryInfo(Path.Combine(_temp.FullName, "outside")); - outside.Create(); - var workspace = new DirectoryInfo(Path.Combine(_temp.FullName, "workspace")); - workspace.Create(); - var junctionInsideWs = Path.Combine(workspace.FullName, "ws-link"); - - if (!TryCreateJunction(junctionInsideWs, outside.FullName, out var skip)) - { - Assert.Inconclusive(skip ?? "Junction creation unavailable."); - return; - } - - var ex = Assert.ThrowsExactly(() => - DynWinrtCodegenService.ResolveOutputDir(workspace, "ws-link/out")); - StringAssert.Contains(ex.Message, "reparse point", - "Error must call out the reparse-point reason for the rejection."); - } -} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenStagingTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenStagingTests.cs deleted file mode 100644 index c46adb11..00000000 --- a/src/winapp-CLI/WinApp.Cli.Tests/DynWinrtCodegenStagingTests.cs +++ /dev/null @@ -1,185 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using WinApp.Cli.Services; - -namespace WinApp.Cli.Tests; - -// split of the historical DynWinrtCodegenServiceTests. -// Scope: RunWithStagingAsync — staging/swap failure-safety contract. -[TestClass] -public class DynWinrtCodegenStagingTests -{ - public TestContext TestContext { get; set; } = null!; - - private DirectoryInfo _temp = null!; - - [TestInitialize] - public void Init() - { - _temp = new DirectoryInfo(Path.Combine(Path.GetTempPath(), $"DynWinrtCodegenStagingTests_{Guid.NewGuid():N}")); - _temp.Create(); - } - - [TestCleanup] - public void Cleanup() - { - try { _temp.Delete(recursive: true); } catch { /* ignore */ } - } - - [TestMethod] - public async Task RunWithStagingAsync_Success_SwapsStagingIntoOutputDir() - { - var outputDir = new DirectoryInfo(Path.Combine(_temp.FullName, "out")); - - await DynWinrtCodegenService.RunWithStagingAsync(outputDir, stagingDir => - { - File.WriteAllText(Path.Combine(stagingDir.FullName, "Foo.js"), "// stub"); - File.WriteAllText(Path.Combine(stagingDir.FullName, "Bar.js"), "// stub"); - return Task.CompletedTask; - }); - - outputDir.Refresh(); - Assert.IsTrue(outputDir.Exists, "Output dir must exist after success"); - Assert.IsTrue(File.Exists(Path.Combine(outputDir.FullName, "Foo.js"))); - Assert.IsTrue(File.Exists(Path.Combine(outputDir.FullName, "Bar.js"))); - Assert.IsTrue(File.Exists(Path.Combine(outputDir.FullName, DynWinrtCodegenService.ManagedMarkerFileName)), - "Marker must be present so subsequent runs are allowed to wipe."); - - var leftovers = outputDir.Parent! - .EnumerateDirectories($"{outputDir.Name}.staging.*") - .ToList(); - Assert.AreEqual(0, leftovers.Count, - $"Staging dirs must be cleaned up; found: {string.Join(", ", leftovers.Select(d => d.Name))}"); - } - - [TestMethod] - public async Task RunWithStagingAsync_Failure_PreservesOldOutputAndCleansStaging() - { - var outputDir = new DirectoryInfo(Path.Combine(_temp.FullName, "out")); - outputDir.Create(); - - File.WriteAllText(Path.Combine(outputDir.FullName, "PrevBinding.js"), "// previous output"); - File.WriteAllText(Path.Combine(outputDir.FullName, DynWinrtCodegenService.ManagedMarkerFileName), - "# managed"); - - await Assert.ThrowsExactlyAsync(() => - DynWinrtCodegenService.RunWithStagingAsync(outputDir, stagingDir => - { - File.WriteAllText(Path.Combine(stagingDir.FullName, "Half.js"), "// half-written"); - throw new InvalidOperationException("simulated codegen crash"); - })); - - outputDir.Refresh(); - Assert.IsTrue(outputDir.Exists, "Previous output dir must be preserved on failure."); - Assert.IsTrue(File.Exists(Path.Combine(outputDir.FullName, "PrevBinding.js")), - "Previous bindings must survive a failed regeneration — this is the whole point of staging."); - Assert.IsFalse(File.Exists(Path.Combine(outputDir.FullName, "Half.js")), - "Half-written staging file must NOT bleed into the output dir."); - - var leftovers = outputDir.Parent! - .EnumerateDirectories($"{outputDir.Name}.staging.*") - .ToList(); - Assert.AreEqual(0, leftovers.Count, - $"Staging dirs must be cleaned up after failure; found: {string.Join(", ", leftovers.Select(d => d.Name))}"); - } - - [TestMethod] - public async Task RunWithStagingAsync_PreservesContentsOnSuccess() - { - var outputDir = new DirectoryInfo(Path.Combine(_temp.FullName, "out")); - - await DynWinrtCodegenService.RunWithStagingAsync(outputDir, stagingDir => - { - for (int i = 0; i < 10; i++) - { - File.WriteAllText(Path.Combine(stagingDir.FullName, $"F{i}.js"), $"// {i}"); - } - Directory.CreateDirectory(Path.Combine(stagingDir.FullName, "sub")); - File.WriteAllText(Path.Combine(stagingDir.FullName, "sub", "deep.js"), "// nested"); - return Task.CompletedTask; - }); - - outputDir.Refresh(); - var jsFiles = outputDir.EnumerateFiles("*.js").Select(f => f.Name).OrderBy(n => n).ToList(); - Assert.AreEqual(10, jsFiles.Count, "All 10 .js files from staging must land in output dir."); - Assert.IsTrue(File.Exists(Path.Combine(outputDir.FullName, "sub", "deep.js")), - "Nested files in staging must survive the swap."); - } - - [TestMethod] - public async Task RunWithStagingAsync_OldOutputWithoutMarker_Throws_StagingCleaned() - { - var outputDir = new DirectoryInfo(Path.Combine(_temp.FullName, "out")); - outputDir.Create(); - File.WriteAllText(Path.Combine(outputDir.FullName, "user-handwritten.js"), "important"); - - await Assert.ThrowsExactlyAsync(() => - DynWinrtCodegenService.RunWithStagingAsync(outputDir, stagingDir => - { - File.WriteAllText(Path.Combine(stagingDir.FullName, "Generated.js"), "// new"); - return Task.CompletedTask; - })); - - Assert.IsTrue(File.Exists(Path.Combine(outputDir.FullName, "user-handwritten.js")), - "Non-managed user file must survive — WipeOutputDirSafely refused."); - - var leftovers = outputDir.Parent! - .EnumerateDirectories($"{outputDir.Name}.staging.*") - .ToList(); - Assert.AreEqual(0, leftovers.Count, - $"Staging dirs must be cleaned up even when swap fails; found: {string.Join(", ", leftovers.Select(d => d.Name))}"); - } - - // Failure during the swap step — backup restore succeeds; everything cleaned up. - [TestMethod] - public async Task RunWithStagingAsync_SwapStepFailure_RestoresOldOutputAndCleansStaging() - { - var outputDir = new DirectoryInfo(Path.Combine(_temp.FullName, "out")); - outputDir.Create(); - File.WriteAllText(Path.Combine(outputDir.FullName, "Prev.js"), "// prev"); - File.WriteAllText(Path.Combine(outputDir.FullName, DynWinrtCodegenService.ManagedMarkerFileName), - "# managed"); - - var lockPath = Path.Combine(outputDir.FullName, "lock-file"); - using (var blocker = new FileStream(lockPath, FileMode.Create, FileAccess.Write, FileShare.None)) - { - await Assert.ThrowsExactlyAsync(async () => - await DynWinrtCodegenService.RunWithStagingAsync(outputDir, stagingDir => - { - File.WriteAllText(Path.Combine(stagingDir.FullName, "New.js"), "// new"); - return Task.CompletedTask; - })); - } - - var stagingLeftovers = outputDir.Parent! - .EnumerateDirectories($"{outputDir.Name}.staging.*") - .ToList(); - Assert.AreEqual(0, stagingLeftovers.Count, - "Staging must be cleaned up after a swap-step failure."); - - var backupLeftovers = outputDir.Parent! - .EnumerateDirectories($"{outputDir.Name}.backup.*") - .ToList(); - Assert.AreEqual(0, backupLeftovers.Count, - "Backup must be cleaned up after a swap-step failure."); - - outputDir.Refresh(); - Assert.IsTrue(outputDir.Exists, "Old output dir must still exist."); - Assert.IsTrue(File.Exists(Path.Combine(outputDir.FullName, "Prev.js")), - "Previous bindings must be preserved — that's the whole point of staging."); - } - - // the catch block in RunWithStagingAsync preserves the - // backup directory on disk when the restore Move also fails, and - // surfaces the preserved path in the thrown IOException so the user - // can recover manually. - // - // This branch cannot be exercised deterministically without - // file-system hooks: triggering it would require both the - // staging→outputDir Move AND the backup→outputDir restore Move to - // fail in sequence within the same call, which is essentially - // impossible to inject from outside the function. Coverage is - // delegated to code review of the catch block — the behavior is - // mechanically verified there. See review #4 M1. -} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InitCommandTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InitCommandTests.cs index 2281bf6b..8c14a1e5 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InitCommandTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InitCommandTests.cs @@ -4,7 +4,6 @@ using Microsoft.Extensions.DependencyInjection; using WinApp.Cli.Commands; using WinApp.Cli.Services; -using WinApp.Cli.Tests.TestDoubles; namespace WinApp.Cli.Tests; @@ -139,296 +138,3 @@ public async Task InitCommand_DoesNotGenerateCertificate() Assert.IsFalse(File.Exists(certPath), "Init should not generate devcert.pfx - certificates should be generated separately with 'cert generate'"); } } - -// Npm-caller bindings prompt — only fires under WINAPP_CLI_CALLER=nodejs-package, -// and only when there's no existing jsBindings: block to preserve. Default Both -// under --use-defaults, no prompt for native callers. [DoNotParallelize] because -// tests mutate that process-wide env var. -[TestClass] -[DoNotParallelize] -public class InitCommandBindingsPromptTests : BaseCommandTests -{ - private string? _savedCaller; - - [TestInitialize] - public void TestSetup() - { - _savedCaller = Environment.GetEnvironmentVariable("WINAPP_CLI_CALLER"); - } - - [TestCleanup] - public void TestTeardown() - { - Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", _savedCaller); - } - - [TestMethod] - public async Task InitCommand_NpmCallerWithUseDefaults_AddsJsBindingsBlock() - { - // Default for npm caller under --use-defaults is "Both", so jsBindings - // must land in winapp.yaml without any explicit flag. - Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); - await File.WriteAllTextAsync( - Path.Combine(_tempDirectory.FullName, "package.json"), - """{"name":"my-app","version":"1.0.0"}"""); - - var initCommand = GetRequiredService(); - var args = new[] { _tempDirectory.FullName, "--config-only", "--use-defaults" }; - var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); - - Assert.AreEqual(0, exitCode); - var configContent = await File.ReadAllTextAsync(Path.Combine(_tempDirectory.FullName, "winapp.yaml")); - StringAssert.Contains(configContent, "jsBindings:", - "npm caller + --use-defaults defaults to Both, so jsBindings: must be added"); - Assert.IsFalse(configContent.Contains("cppProjections: false"), - "Both mode keeps cppProjections at default (true); explicit false should not be written"); - } - - [TestMethod] - public async Task InitCommand_NativeCallerWithUseDefaults_OmitsJsBindingsBlock() - { - // Standalone CLI (winget) keeps historical C++-only behavior even - // under --use-defaults — no prompt, no jsBindings. - Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", null); - - var initCommand = GetRequiredService(); - var args = new[] { _tempDirectory.FullName, "--config-only", "--use-defaults" }; - var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); - - Assert.AreEqual(0, exitCode); - var configContent = await File.ReadAllTextAsync(Path.Combine(_tempDirectory.FullName, "winapp.yaml")); - Assert.IsFalse(configContent.Contains("jsBindings:"), - "Native caller must not gain a jsBindings block — bindings prompt only fires for npm caller"); - } - - [TestMethod] - public async Task InitCommand_NpmCallerOnDotNetProject_SilentlyFallsBackToCppOnly() - { - // .NET projects can't host JS bindings; rather than asking a question - // with only one valid answer (and tripping the .NET guard when - // --use-defaults → Both), the prompt path silently downgrades to - // CppOnly so dotnet sample tests / CI succeed without intervention. - Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); - var csprojPath = Path.Combine(_tempDirectory.FullName, "Sample.csproj"); - await File.WriteAllTextAsync(csprojPath, - "\n" - + " \n" - + " Exe\n" - + " net10.0-windows10.0.26100.0\n" - + " \n" - + "\n"); - - var initCommand = GetRequiredService(); - var args = new[] { _tempDirectory.FullName, "--use-defaults", "--config-only" }; - var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); - - Assert.AreEqual(0, exitCode, - ".NET project + npm caller + --use-defaults must succeed (silently downgraded to CppOnly)."); - var combined = ConsoleStdErr.ToString() + ConsoleStdOut.ToString() + TestAnsiConsole.Output; - Assert.IsFalse( - combined.Contains("JS/TS bindings are not supported on .NET", StringComparison.OrdinalIgnoreCase), - $".NET projects must not surface the JS-bindings rejection error — the prompt silently picks CppOnly. Combined output: {combined}"); - // winapp.yaml may or may not be written depending on the SDK install - // mode chosen for .NET projects (which is typically None — .NET pulls - // SDK via NuGet). The key invariant is that, if one is written, it - // must NOT contain a jsBindings block. - var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); - if (File.Exists(configPath)) - { - var configContent = await File.ReadAllTextAsync(configPath); - Assert.IsFalse(configContent.Contains("jsBindings:"), - ".NET project must not gain a jsBindings block — the prompt path silently picks CppOnly."); - } - } - - [TestMethod] - public async Task InitCommand_NpmCallerOnDotNetProjectWithHandEditedJsBindings_RejectedWithActionableError() - { - // Defense-in-depth: if a user manually adds a jsBindings: block to a - // .NET project's winapp.yaml, the .NET guard must still fire with an - // actionable message rather than letting codegen produce junk. - Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); - var csprojPath = Path.Combine(_tempDirectory.FullName, "Sample.csproj"); - await File.WriteAllTextAsync(csprojPath, - "\n" - + " \n" - + " Exe\n" - + " net10.0-windows10.0.26100.0\n" - + " \n" - + "\n"); - var existing = """ - packages: - - name: Microsoft.WindowsAppSDK - version: 1.8.39 - - name: Microsoft.Windows.SDK.BuildTools - version: 10.0.26100.5040 - jsBindings: - output: bindings/winrt - lang: js - """; - var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); - await File.WriteAllTextAsync(configPath, existing); - - var initCommand = GetRequiredService(); - var args = new[] { _tempDirectory.FullName, "--config-only", "--use-defaults" }; - var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); - - Assert.AreEqual(1, exitCode, - "Hand-edited jsBindings: on a .NET project must be rejected by the .NET guard."); - var combined = ConsoleStdErr.ToString() + ConsoleStdOut.ToString() + TestAnsiConsole.Output; - Assert.IsTrue( - combined.Contains(".NET", StringComparison.OrdinalIgnoreCase) - && combined.Contains("not supported", StringComparison.OrdinalIgnoreCase), - $"Error must call out the .NET-not-supported case. Combined output: {combined}"); - } - - [TestMethod] - public async Task InitCommand_NpmCallerWithSetupSdksNone_SilentlyFallsBackToCppOnly() - { - // JS bindings need SDK packages for codegen's winmd source. Rather - // than tripping the SDK-None guard when --use-defaults → Both, the - // prompt path silently picks CppOnly for `--setup-sdks none` so - // samples like rust-app (which don't need winapp to install SDKs) - // succeed without intervention. - Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); - - var initCommand = GetRequiredService(); - var args = new[] { _tempDirectory.FullName, "--use-defaults", "--setup-sdks", "none", "--config-only" }; - var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); - - Assert.AreEqual(0, exitCode, - "--setup-sdks none + npm caller must succeed (silently downgraded to CppOnly)."); - var combined = ConsoleStdErr.ToString() + ConsoleStdOut.ToString() + TestAnsiConsole.Output; - Assert.IsFalse( - combined.Contains("JS/TS bindings need SDK packages", StringComparison.OrdinalIgnoreCase), - $"--setup-sdks none must not surface the SDK-None rejection error — the prompt silently picks CppOnly. Combined output: {combined}"); - // winapp.yaml may or may not be written when SdkInstallMode==None; - // the key invariant is that, if one is written, it must NOT contain - // a jsBindings block. - var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); - if (File.Exists(configPath)) - { - var configContent = await File.ReadAllTextAsync(configPath); - Assert.IsFalse(configContent.Contains("jsBindings:"), - "--setup-sdks none must not gain a jsBindings block — the prompt path silently picks CppOnly."); - } - } - - [TestMethod] - public async Task InitCommand_NpmCallerWithHandEditedJsBindingsAndSetupSdksNone_RejectedWithActionableError() - { - // Defense-in-depth: if a user manually adds a jsBindings: block to - // winapp.yaml AND runs init with --setup-sdks none, the SDK-None - // guard must still fire — codegen has no winmd source to consume. - Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); - var existing = """ - packages: - - name: Microsoft.WindowsAppSDK - version: 1.8.39 - - name: Microsoft.Windows.SDK.BuildTools - version: 10.0.26100.5040 - jsBindings: - output: bindings/winrt - lang: js - """; - var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); - await File.WriteAllTextAsync(configPath, existing); - - var initCommand = GetRequiredService(); - var args = new[] { _tempDirectory.FullName, "--config-only", "--use-defaults", "--setup-sdks", "none" }; - var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); - - Assert.AreEqual(1, exitCode, - "Hand-edited jsBindings: + --setup-sdks none must be rejected by the SDK-None guard."); - var combined = ConsoleStdErr.ToString() + ConsoleStdOut.ToString() + TestAnsiConsole.Output; - Assert.IsTrue( - combined.Contains("none", StringComparison.OrdinalIgnoreCase) - && combined.Contains("SDK packages", StringComparison.OrdinalIgnoreCase), - $"Error must call out the setup-sdks=none conflict. Combined output: {combined}"); - } - - [TestMethod] - public async Task InitCommand_NpmCallerWithExistingJsBindings_PreservesUserChoice() - { - // Existing yaml that already declares jsBindings: must not be - // re-prompted; the existing choice (here: JS-only via - // cppProjections: false) round-trips through init. - Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); - var existing = """ - cppProjections: false - packages: - - name: Microsoft.WindowsAppSDK - version: 1.8.39 - - name: Microsoft.Windows.SDK.BuildTools - version: 10.0.26100.5040 - jsBindings: - output: bindings/winrt - lang: js - """; - var configPath = Path.Combine(_tempDirectory.FullName, "winapp.yaml"); - await File.WriteAllTextAsync(configPath, existing); - - var initCommand = GetRequiredService(); - var args = new[] { _tempDirectory.FullName, "--config-only", "--use-defaults" }; - var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); - - Assert.AreEqual(0, exitCode); - var configContent = await File.ReadAllTextAsync(configPath); - StringAssert.Contains(configContent, "jsBindings:", - "Existing jsBindings block must survive re-init."); - StringAssert.Contains(configContent, "Microsoft.WindowsAppSDK", - "Existing pinned packages must survive re-init."); - } -} - -// Verifies the init → orchestration wiring delivers the runtime-dep -// injection call once the npm-caller prompt opts into JS bindings. -[TestClass] -[DoNotParallelize] -public class InitCommandBindingsWiringTests : BaseCommandTests -{ - private FakeJsBindingsWorkspaceService _fakeJsBindings = null!; - private string? _savedCaller; - - protected override IServiceCollection ConfigureServices(IServiceCollection services) - { - _fakeJsBindings = new FakeJsBindingsWorkspaceService(); - var existing = services.FirstOrDefault(d => d.ServiceType == typeof(IJsBindingsWorkspaceService)); - if (existing is not null) - { - services.Remove(existing); - } - services.AddSingleton(_fakeJsBindings); - return services; - } - - [TestInitialize] - public void TestSetup() - { - _savedCaller = Environment.GetEnvironmentVariable("WINAPP_CLI_CALLER"); - Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", "nodejs-package"); - } - - [TestCleanup] - public void TestTeardown() - { - Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", _savedCaller); - } - - [TestMethod] - public async Task InitCommand_NpmCallerWithUseDefaults_InvokesEnsureRuntimeDependency() - { - // npm caller + --use-defaults → Both → must wire @microsoft/dynwinrt. - File.WriteAllText( - Path.Combine(_tempDirectory.FullName, "package.json"), - """{"name":"app","version":"1.0.0","dependencies":{}}"""); - - var initCommand = GetRequiredService(); - var args = new[] { _tempDirectory.FullName, "--config-only", "--use-defaults" }; - var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); - - Assert.AreEqual(0, exitCode); - Assert.IsTrue(_fakeJsBindings.EnsureRuntimeDependencyCalled, - "Default Both for npm caller must call IJsBindingsWorkspaceService.EnsureRuntimeDependencyAndPrintHint."); - } -} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/JsBindingsPresetsTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/JsBindingsPresetsTests.cs deleted file mode 100644 index 47c309f2..00000000 --- a/src/winapp-CLI/WinApp.Cli.Tests/JsBindingsPresetsTests.cs +++ /dev/null @@ -1,319 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using WinApp.Cli.Services; - -namespace WinApp.Cli.Tests; - -[TestClass] -public class JsBindingsPresetsTests -{ - private static readonly string[] _arr00 = ["Microsoft.WindowsAppSDK.AI", "Some.Vendor.Pkg"]; - private static readonly string[] _arr01 = ["Microsoft.WindowsAppSDK.AI", "Microsoft.WindowsAppSDK.WinUI"]; - private static readonly string[] _arr02 = ["Microsoft.WindowsAppSDK.AI"]; - - // Per-package winmd categorization — emit / ref-only / skip. - - [TestMethod] - public void ClassifyPackage_WinUI_IsSkipped() - { - // Pure XAML composables — drop entirely. - Assert.AreEqual(WinmdPackageCategory.Skip, - JsBindingsPresets.ClassifyPackage("Microsoft.WindowsAppSDK.WinUI")); - Assert.AreEqual(WinmdPackageCategory.Skip, - JsBindingsPresets.ClassifyPackage("microsoft.windowsappsdk.winui"), - "Package ID match must be case-insensitive (NuGet cache lowercases)."); - } - - [TestMethod] - public void ClassifyPackage_InteractiveExperiences_IsRefOnly() - { - // Ships Microsoft.UI.WindowId / Microsoft.Graphics.PointInt32 etc. - // — primitives referenced by other packages. - Assert.AreEqual(WinmdPackageCategory.RefOnly, - JsBindingsPresets.ClassifyPackage("Microsoft.WindowsAppSDK.InteractiveExperiences")); - Assert.AreEqual(WinmdPackageCategory.RefOnly, - JsBindingsPresets.ClassifyPackage("microsoft.windowsappsdk.interactiveexperiences")); - } - - [TestMethod] - public void ClassifyPackage_UnknownPackage_DefaultsToEmit() - { - Assert.AreEqual(WinmdPackageCategory.Emit, - JsBindingsPresets.ClassifyPackage("Microsoft.WindowsAppSDK.AI")); - Assert.AreEqual(WinmdPackageCategory.Emit, - JsBindingsPresets.ClassifyPackage("Microsoft.WindowsAppSDK.Foundation")); - Assert.AreEqual(WinmdPackageCategory.Emit, - JsBindingsPresets.ClassifyPackage("anything.else")); - Assert.AreEqual(WinmdPackageCategory.Emit, - JsBindingsPresets.ClassifyPackage(""), - "Empty/null package id (e.g. vendor winmds outside the cache) defaults to Emit."); - } - - [TestMethod] - public void ExtractPackageIdFromPath_FlatMetadataLayout_ReturnsPackageId() - { - // The Microsoft.WindowsAppSDK.AI layout: metadata files sit directly under metadata/. - var p = @"C:\Users\u\.nuget\packages\microsoft.windowsappsdk.ai\1.8.39\metadata\Microsoft.Windows.AI.winmd"; - Assert.AreEqual("microsoft.windowsappsdk.ai", JsBindingsPresets.ExtractPackageIdFromPath(p)); - } - - [TestMethod] - public void ExtractPackageIdFromPath_NestedSdkVersionLayout_ReturnsPackageId() - { - // The InteractiveExperiences layout: metadata files nested under metadata/10.0.18362.0/. - var p = @"C:\Users\u\.nuget\packages\microsoft.windowsappsdk.interactiveexperiences\1.8.251104001\metadata\10.0.18362.0\Microsoft.UI.winmd"; - Assert.AreEqual("microsoft.windowsappsdk.interactiveexperiences", - JsBindingsPresets.ExtractPackageIdFromPath(p)); - } - - [TestMethod] - public void ExtractPackageIdFromPath_NonNuGetPath_ReturnsNull() - { - // Vendor winmd outside the cache — no "packages" segment. - var p = @"C:\src\my-project\vendor\Custom.winmd"; - Assert.IsNull(JsBindingsPresets.ExtractPackageIdFromPath(p)); - } - - [TestMethod] - public void ExtractPackageIdFromPath_ForwardSlashPath_AlsoWorks() - { - // Defensive: we may get either separator on Windows. - var p = "C:/Users/u/.nuget/packages/microsoft.windowsappsdk.ai/1.8.39/metadata/Foo.winmd"; - Assert.AreEqual("microsoft.windowsappsdk.ai", JsBindingsPresets.ExtractPackageIdFromPath(p)); - } - - [TestMethod] - public void PartitionByPackageCategory_MixedSet_SplitsCorrectly() - { - var files = new[] - { - new FileInfo(@"C:\u\.nuget\packages\microsoft.windowsappsdk.ai\1.8.39\metadata\Microsoft.Windows.AI.winmd"), - new FileInfo(@"C:\u\.nuget\packages\microsoft.windowsappsdk.foundation\1.8.0\metadata\Microsoft.Windows.Storage.winmd"), - new FileInfo(@"C:\u\.nuget\packages\microsoft.windowsappsdk.winui\1.8.0\metadata\Microsoft.UI.Xaml.winmd"), - new FileInfo(@"C:\u\.nuget\packages\microsoft.windowsappsdk.interactiveexperiences\1.8.0\metadata\10.0.18362.0\Microsoft.UI.winmd"), - new FileInfo(@"C:\u\.nuget\packages\microsoft.windowsappsdk.interactiveexperiences\1.8.0\metadata\10.0.18362.0\Microsoft.Graphics.winmd"), - new FileInfo(@"C:\src\vendor\MyCo.Custom.winmd"), - }; - - var p = JsBindingsPresets.PartitionByPackageCategory(files); - - Assert.AreEqual(3, p.Emit.Count, - "AI + Foundation + vendor winmd should land in Emit"); - Assert.IsTrue(p.Emit.Any(f => f.FullName.EndsWith("Microsoft.Windows.AI.winmd", StringComparison.OrdinalIgnoreCase))); - Assert.IsTrue(p.Emit.Any(f => f.FullName.EndsWith("Microsoft.Windows.Storage.winmd", StringComparison.OrdinalIgnoreCase))); - Assert.IsTrue(p.Emit.Any(f => f.FullName.EndsWith("MyCo.Custom.winmd", StringComparison.OrdinalIgnoreCase)), - "Vendor winmds outside the NuGet cache must default to Emit."); - - Assert.AreEqual(2, p.RefOnly.Count, - "Both InteractiveExperiences winmds (Microsoft.UI + Microsoft.Graphics) go to RefOnly"); - - Assert.AreEqual(1, p.Skipped.Count, - "Microsoft.UI.Xaml.winmd from the WinUI package is dropped entirely"); - Assert.IsTrue(p.Skipped[0].FullName.EndsWith("Microsoft.UI.Xaml.winmd", StringComparison.OrdinalIgnoreCase)); - } - - [TestMethod] - public void PartitionByPackageCategory_EmptyInput_ReturnsAllEmpty() - { - var p = JsBindingsPresets.PartitionByPackageCategory(Array.Empty()); - Assert.AreEqual(0, p.Emit.Count); - Assert.AreEqual(0, p.RefOnly.Count); - Assert.AreEqual(0, p.Skipped.Count); - } - - // ------------------------------------------------------------------------- - // v2.3 — PackageCategoryOverrides - // ------------------------------------------------------------------------- - - [TestMethod] - public void ClassifyPackage_UserEmit_OverridesDefaultSkip() - { - // Default would Skip WinUI; user force-emits → must become Emit. - var ov = new JsBindingsPresets.PackageCategoryOverrides - { - Emit = new HashSet(StringComparer.OrdinalIgnoreCase) { "Microsoft.WindowsAppSDK.WinUI" }, - }; - Assert.AreEqual(WinmdPackageCategory.Emit, - JsBindingsPresets.ClassifyPackage("Microsoft.WindowsAppSDK.WinUI", ov)); - } - - [TestMethod] - public void ClassifyPackage_UserSkip_AppendedToDefault() - { - var ov = new JsBindingsPresets.PackageCategoryOverrides - { - Skip = new HashSet(StringComparer.OrdinalIgnoreCase) { "Some.New.XAML.Package" }, - }; - Assert.AreEqual(WinmdPackageCategory.Skip, - JsBindingsPresets.ClassifyPackage("Some.New.XAML.Package", ov)); - // Default skip list still honored. - Assert.AreEqual(WinmdPackageCategory.Skip, - JsBindingsPresets.ClassifyPackage("Microsoft.WindowsAppSDK.WinUI", ov)); - } - - [TestMethod] - public void ClassifyPackage_UserRefOnly_AppendedToDefault() - { - var ov = new JsBindingsPresets.PackageCategoryOverrides - { - RefOnly = new HashSet(StringComparer.OrdinalIgnoreCase) { "Vendor.PrimitiveTypes" }, - }; - Assert.AreEqual(WinmdPackageCategory.RefOnly, - JsBindingsPresets.ClassifyPackage("Vendor.PrimitiveTypes", ov)); - // InteractiveExperiences still ref-only by default. - Assert.AreEqual(WinmdPackageCategory.RefOnly, - JsBindingsPresets.ClassifyPackage("Microsoft.WindowsAppSDK.InteractiveExperiences", ov)); - } - - [TestMethod] - public void ClassifyPackage_UserEmit_BeatsBothUserSkipAndUserRefOnly() - { - // If users list the same package in both Skip and Emit, Emit wins - // (most permissive — they explicitly want bindings). - var ov = new JsBindingsPresets.PackageCategoryOverrides - { - Skip = new HashSet(StringComparer.OrdinalIgnoreCase) { "Foo" }, - RefOnly = new HashSet(StringComparer.OrdinalIgnoreCase) { "Foo" }, - Emit = new HashSet(StringComparer.OrdinalIgnoreCase) { "Foo" }, - }; - Assert.AreEqual(WinmdPackageCategory.Emit, - JsBindingsPresets.ClassifyPackage("Foo", ov)); - } - - [TestMethod] - public void PackageCategoryOverrides_From_NullConfig_ReturnsEmpty() - { - var ov = JsBindingsPresets.PackageCategoryOverrides.From(null); - Assert.IsNull(ov.Skip); - Assert.IsNull(ov.RefOnly); - Assert.IsNull(ov.Emit); - } - - [TestMethod] - public void PackageCategoryOverrides_From_PopulatedConfig_MapsAllThreeLists() - { - var cfg = new WinApp.Cli.Models.JsBindingsConfig - { - SkipPackages = { "S1" }, - RefOnlyPackages = { "R1", "R2" }, - EmitPackages = { "E1" }, - }; - var ov = JsBindingsPresets.PackageCategoryOverrides.From(cfg); - Assert.IsNotNull(ov.Skip); - Assert.IsTrue(ov.Skip!.Contains("S1")); - Assert.IsNotNull(ov.RefOnly); - Assert.AreEqual(2, ov.RefOnly!.Count); - Assert.IsNotNull(ov.Emit); - Assert.IsTrue(ov.Emit!.Contains("E1")); - } - - [TestMethod] - public void PartitionByPackageCategory_UserOverrides_RedirectPackage() - { - // WinUI default = skip; force-emit it via override → ends up in Emit. - var files = new[] - { - new FileInfo(@"C:\u\.nuget\packages\microsoft.windowsappsdk.ai\1.8.39\metadata\AI.winmd"), - new FileInfo(@"C:\u\.nuget\packages\microsoft.windowsappsdk.winui\1.8\metadata\Xaml.winmd"), - }; - var ov = new JsBindingsPresets.PackageCategoryOverrides - { - Emit = new HashSet(StringComparer.OrdinalIgnoreCase) { "Microsoft.WindowsAppSDK.WinUI" }, - }; - var p = JsBindingsPresets.PartitionByPackageCategory(files, ov); - Assert.AreEqual(2, p.Emit.Count, "Both AI and WinUI should now emit (user force-emit on WinUI)."); - Assert.AreEqual(0, p.Skipped.Count); - } - - // emit scope demotes out-of-scope emit-category packages - // to RefOnly so codegen still has metadata for cross-package type - // resolution. This is the core regression test for the live-discovery - // bug where scope was applied BEFORE discovery, dropping refs entirely. - [TestMethod] - public void PartitionByPackageCategory_EmitScope_OutOfScopeEmitPackages_DemotedToRefOnly() - { - var files = new[] - { - new FileInfo(@"C:\u\.nuget\packages\microsoft.windowsappsdk.ai\1.8.39\metadata\AI.winmd"), - // Core WindowsAppSDK is NOT in scope but its types are - // referenced by AI — must end up as RefOnly, not dropped. - new FileInfo(@"C:\u\.nuget\packages\microsoft.windowsappsdk\1.8.39\lib\Core.winmd"), - new FileInfo(@"C:\u\.nuget\packages\microsoft.web.webview2\1.0.0\runtimes\WebView2.winmd"), - }; - - var scope = _arr02; - var p = JsBindingsPresets.PartitionByPackageCategory( - files, overrides: null, nugetCacheRoot: null, emitScope: scope); - - Assert.AreEqual(1, p.Emit.Count, "Only AI is in scope, so only AI emits."); - Assert.IsTrue(p.Emit[0].FullName.EndsWith("AI.winmd", StringComparison.OrdinalIgnoreCase)); - - Assert.AreEqual(2, p.RefOnly.Count, - "Out-of-scope emit-category packages (core SDK + WebView2) MUST be demoted to RefOnly, " - + "NOT dropped — codegen needs them for type resolution."); - Assert.IsTrue(p.RefOnly.Any(f => f.FullName.EndsWith("Core.winmd", StringComparison.OrdinalIgnoreCase))); - Assert.IsTrue(p.RefOnly.Any(f => f.FullName.EndsWith("WebView2.winmd", StringComparison.OrdinalIgnoreCase))); - } - - [TestMethod] - public void PartitionByPackageCategory_EmitScope_NullOrEmpty_NoFiltering() - { - // No emit scope = full default partitioning (no demotion happens). - var files = new[] - { - new FileInfo(@"C:\u\.nuget\packages\microsoft.windowsappsdk.ai\1.8.39\metadata\AI.winmd"), - new FileInfo(@"C:\u\.nuget\packages\microsoft.windowsappsdk\1.8.39\lib\Core.winmd"), - }; - - var pNull = JsBindingsPresets.PartitionByPackageCategory(files, emitScope: null); - Assert.AreEqual(2, pNull.Emit.Count, "Null scope = full emit."); - Assert.AreEqual(0, pNull.RefOnly.Count); - - var pEmpty = JsBindingsPresets.PartitionByPackageCategory(files, emitScope: Array.Empty()); - Assert.AreEqual(2, pEmpty.Emit.Count, "Empty scope = full emit (same as null)."); - } - - [TestMethod] - public void PartitionByPackageCategory_EmitScope_SkipCategoryWins() - { - // Skip-classified packages stay Skipped even when in scope — the - // user-override skip is stronger than scope inclusion. - var files = new[] - { - new FileInfo(@"C:\u\.nuget\packages\microsoft.windowsappsdk.ai\1.8.39\metadata\AI.winmd"), - // WinUI is in the default-skip set. - new FileInfo(@"C:\u\.nuget\packages\microsoft.windowsappsdk.winui\1.8\metadata\Xaml.winmd"), - }; - - var scope = _arr01; - var p = JsBindingsPresets.PartitionByPackageCategory( - files, overrides: null, nugetCacheRoot: null, emitScope: scope); - - Assert.AreEqual(1, p.Emit.Count, "AI emits."); - Assert.AreEqual(1, p.Skipped.Count, "WinUI stays skipped even though in scope."); - } - - [TestMethod] - public void PartitionByPackageCategory_EmitScope_RefOnlyCategoryWins() - { - // RefOnly-classified packages stay RefOnly when in scope — the - // classification is stronger. - var files = new[] - { - new FileInfo(@"C:\u\.nuget\packages\microsoft.windowsappsdk.ai\1.8.39\metadata\AI.winmd"), - new FileInfo(@"C:\u\.nuget\packages\some.vendor.pkg\1.0\lib\Vendor.winmd"), - }; - var ov = new JsBindingsPresets.PackageCategoryOverrides - { - RefOnly = new HashSet(StringComparer.OrdinalIgnoreCase) { "Some.Vendor.Pkg" }, - }; - - var p = JsBindingsPresets.PartitionByPackageCategory( - files, ov, nugetCacheRoot: null, - emitScope: _arr00); - - Assert.AreEqual(1, p.Emit.Count, "AI emits."); - Assert.AreEqual(1, p.RefOnly.Count, "Vendor stays RefOnly via classification override."); - } -} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/NpmWrapperVersionProviderTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/NpmWrapperVersionProviderTests.cs deleted file mode 100644 index cad5e53f..00000000 --- a/src/winapp-CLI/WinApp.Cli.Tests/NpmWrapperVersionProviderTests.cs +++ /dev/null @@ -1,168 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using WinApp.Cli.Services; - -namespace WinApp.Cli.Tests; - -// Tests for NpmWrapperVersionProvider. ProcessPath in `dotnet test` points -// at testhost.exe (outside any npm layout), so we exercise the failure path. -[TestClass] -public class NpmWrapperVersionProviderTests -{ - private DirectoryInfo _temp = null!; - - [TestInitialize] - public void Init() - { - _temp = new DirectoryInfo(Path.Combine(Path.GetTempPath(), $"NpmWrapperTests_{Guid.NewGuid():N}")); - _temp.Create(); - } - - [TestCleanup] - public void Cleanup() - { - try { _temp.Delete(recursive: true); } catch { /* ignore */ } - } - - [TestMethod] - public void DynWinrtVersion_OutsideNpmLayout_ThrowsInvalidOperationWithDIHint() - { - var provider = new NpmWrapperVersionProvider(); - - var ex = Assert.ThrowsExactly( - () => _ = provider.DynWinrtVersion); - StringAssert.Contains(ex.Message, "@microsoft/winappcli"); - StringAssert.Contains(ex.Message, "INpmWrapperVersionProvider", - "Error must point users at the DI override they need to register"); - } - - [TestMethod] - public void DynWinrtCodegenVersion_OutsideNpmLayout_ThrowsInvalidOperation() - { - var provider = new NpmWrapperVersionProvider(); - Assert.ThrowsExactly( - () => _ = provider.DynWinrtCodegenVersion); - } - - [TestMethod] - public void Versions_AreLazyAndShared() - { - // Lazy should cache and replay the same failure across both props. - var provider = new NpmWrapperVersionProvider(); - var first = Assert.ThrowsExactly( - () => _ = provider.DynWinrtVersion); - var second = Assert.ThrowsExactly( - () => _ = provider.DynWinrtCodegenVersion); - Assert.AreEqual(first.Message, second.Message, - "Lazy should cache and replay the same failure"); - } - - // ── Happy-path / structural tests against the LocateFrom seam ─────── - - [TestMethod] - public void LocateFrom_ValidWrapperLayout_ReturnsCodegenDependencyVersion() - { - // Simulates node_modules/@microsoft/winappcli/{package.json + bin//winapp.exe} - var pkgDir = Directory.CreateDirectory( - Path.Combine(_temp.FullName, "node_modules", "@microsoft", "winappcli")); - var binDir = Directory.CreateDirectory(Path.Combine(pkgDir.FullName, "bin", "win-arm64")); - File.WriteAllText(Path.Combine(pkgDir.FullName, "package.json"), """ - { - "name": "@microsoft/winappcli", - "version": "0.3.2", - "dependencies": { - "@microsoft/dynwinrt-codegen": "0.1.0-preview.1" - } - } - """); - - var version = NpmWrapperVersionProvider.LocateFrom(binDir.FullName); - - Assert.AreEqual("0.1.0-preview.1", version); - } - - [TestMethod] - public void LocateFrom_UnrelatedPackageJsonInParent_KeepsWalkingForWrapper() - { - // Common case: project package.json appears before the wrapper one. - var workspace = Directory.CreateDirectory(Path.Combine(_temp.FullName, "user-workspace")); - File.WriteAllText(Path.Combine(workspace.FullName, "package.json"), """ - { "name": "some-user-project", "version": "1.0.0" } - """); - var wrapperDir = Directory.CreateDirectory( - Path.Combine(workspace.FullName, "node_modules", "@microsoft", "winappcli")); - File.WriteAllText(Path.Combine(wrapperDir.FullName, "package.json"), """ - { - "name": "@microsoft/winappcli", - "version": "0.3.2", - "dependencies": { "@microsoft/dynwinrt-codegen": "9.9.9-from-wrapper" } - } - """); - var binDir = Directory.CreateDirectory(Path.Combine(wrapperDir.FullName, "bin", "win-x64")); - - var version = NpmWrapperVersionProvider.LocateFrom(binDir.FullName); - - Assert.AreEqual("9.9.9-from-wrapper", version, - "Walker must skip unrelated package.json files and only accept the wrapper one."); - } - - [TestMethod] - public void LocateFrom_WrapperPackageJsonWithoutDependencies_Throws() - { - var pkgDir = Directory.CreateDirectory( - Path.Combine(_temp.FullName, "node_modules", "@microsoft", "winappcli")); - File.WriteAllText(Path.Combine(pkgDir.FullName, "package.json"), """ - { "name": "@microsoft/winappcli", "version": "0.3.2" } - """); - var binDir = Directory.CreateDirectory(Path.Combine(pkgDir.FullName, "bin")); - - var ex = Assert.ThrowsExactly( - () => NpmWrapperVersionProvider.LocateFrom(binDir.FullName)); - StringAssert.Contains(ex.Message, "dependencies"); - } - - [TestMethod] - public void LocateFrom_WrapperPackageJsonMissingCodegenDep_Throws() - { - var pkgDir = Directory.CreateDirectory( - Path.Combine(_temp.FullName, "node_modules", "@microsoft", "winappcli")); - File.WriteAllText(Path.Combine(pkgDir.FullName, "package.json"), """ - { - "name": "@microsoft/winappcli", - "version": "0.3.2", - "dependencies": { "some-other-pkg": "1.0.0" } - } - """); - var binDir = Directory.CreateDirectory(Path.Combine(pkgDir.FullName, "bin")); - - var ex = Assert.ThrowsExactly( - () => NpmWrapperVersionProvider.LocateFrom(binDir.FullName)); - StringAssert.Contains(ex.Message, "@microsoft/dynwinrt-codegen"); - } - - [TestMethod] - public void LocateFrom_MalformedPackageJson_Throws() - { - var pkgDir = Directory.CreateDirectory( - Path.Combine(_temp.FullName, "node_modules", "@microsoft", "winappcli")); - File.WriteAllText(Path.Combine(pkgDir.FullName, "package.json"), "{ not valid json"); - var binDir = Directory.CreateDirectory(Path.Combine(pkgDir.FullName, "bin")); - - var ex = Assert.ThrowsExactly( - () => NpmWrapperVersionProvider.LocateFrom(binDir.FullName)); - StringAssert.Contains(ex.Message, "Failed to parse"); - } - - [TestMethod] - public void LocateFrom_NoWrapperAnywhere_ThrowsWithDIHint() - { - // No package.json at any ancestor. - var bare = Directory.CreateDirectory(Path.Combine(_temp.FullName, "bare")); - - var ex = Assert.ThrowsExactly( - () => NpmWrapperVersionProvider.LocateFrom(bare.FullName)); - StringAssert.Contains(ex.Message, "@microsoft/winappcli"); - StringAssert.Contains(ex.Message, "INpmWrapperVersionProvider"); - } -} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/PackageManagerDetectorTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/PackageManagerDetectorTests.cs deleted file mode 100644 index 025a2a93..00000000 --- a/src/winapp-CLI/WinApp.Cli.Tests/PackageManagerDetectorTests.cs +++ /dev/null @@ -1,149 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using WinApp.Cli.Services; - -namespace WinApp.Cli.Tests; - -// Unit tests for PackageManagerDetector. Covers each detection -// signal (Corepack packageManager field, lockfile sniffing, fallback) -// and the priority ordering between them. -[TestClass] -public class PackageManagerDetectorTests -{ - private DirectoryInfo _tempDir = null!; - private PackageManagerDetector _detector = null!; - - [TestInitialize] - public void Setup() - { - _tempDir = new DirectoryInfo( - Path.Combine(Path.GetTempPath(), $"PMDetectorTests_{Guid.NewGuid():N}")); - _tempDir.Create(); - _detector = new PackageManagerDetector(); - } - - [TestCleanup] - public void Teardown() - { - try { _tempDir.Delete(true); } catch { /* ignore */ } - } - - [TestMethod] - public void Detect_NoSignals_ReturnsNpmDefault() - { - var result = _detector.Detect(_tempDir); - Assert.AreEqual("npm", result.Name); - Assert.AreEqual("npm install", result.InstallCommand); - } - - [TestMethod] - public void Detect_PnpmLockfile_ReturnsPnpm() - { - File.WriteAllText(Path.Combine(_tempDir.FullName, "pnpm-lock.yaml"), "lockfileVersion: 9\n"); - var result = _detector.Detect(_tempDir); - Assert.AreEqual("pnpm", result.Name); - Assert.AreEqual("pnpm install", result.InstallCommand); - } - - [TestMethod] - public void Detect_YarnLockfile_ReturnsYarn() - { - File.WriteAllText(Path.Combine(_tempDir.FullName, "yarn.lock"), "# yarn lockfile v1\n"); - var result = _detector.Detect(_tempDir); - Assert.AreEqual("yarn", result.Name); - Assert.AreEqual("yarn install", result.InstallCommand); - } - - [TestMethod] - public void Detect_BunLockfile_ReturnsBun() - { - // Bun ships either `bun.lockb` (binary, older) or `bun.lock` (text, newer). - File.WriteAllBytes(Path.Combine(_tempDir.FullName, "bun.lockb"), new byte[] { 0x00, 0x01 }); - var result = _detector.Detect(_tempDir); - Assert.AreEqual("bun", result.Name); - Assert.AreEqual("bun install", result.InstallCommand); - } - - [TestMethod] - public void Detect_BunTextLockfile_ReturnsBun() - { - File.WriteAllText(Path.Combine(_tempDir.FullName, "bun.lock"), "{}\n"); - var result = _detector.Detect(_tempDir); - Assert.AreEqual("bun", result.Name); - } - - [TestMethod] - public void Detect_PackageLockJson_ReturnsNpm() - { - File.WriteAllText(Path.Combine(_tempDir.FullName, "package-lock.json"), "{}\n"); - var result = _detector.Detect(_tempDir); - Assert.AreEqual("npm", result.Name); - Assert.AreEqual("npm install", result.InstallCommand); - } - - [TestMethod] - public void Detect_PnpmLockBeatsPackageLock_PnpmWins() - { - // When both lockfiles exist (e.g. user migrated), pnpm-lock.yaml is - // the stronger signal because package-lock.json can be auto-created - // by other tools. - File.WriteAllText(Path.Combine(_tempDir.FullName, "pnpm-lock.yaml"), "lockfileVersion: 9\n"); - File.WriteAllText(Path.Combine(_tempDir.FullName, "package-lock.json"), "{}\n"); - var result = _detector.Detect(_tempDir); - Assert.AreEqual("pnpm", result.Name); - } - - [TestMethod] - public void Detect_CorepackPackageManagerField_BeatsLockfile() - { - // Even with an npm lockfile, an explicit `packageManager: pnpm@…` - // declaration in package.json is the authoritative signal. - File.WriteAllText(Path.Combine(_tempDir.FullName, "package-lock.json"), "{}\n"); - File.WriteAllText( - Path.Combine(_tempDir.FullName, "package.json"), - "{ \"packageManager\": \"pnpm@9.5.0\" }"); - var result = _detector.Detect(_tempDir); - Assert.AreEqual("pnpm", result.Name); - Assert.AreEqual("pnpm install", result.InstallCommand); - } - - [TestMethod] - public void Detect_CorepackWithShaSuffix_StillParses() - { - // Corepack format allows `@+sha512.`. - File.WriteAllText( - Path.Combine(_tempDir.FullName, "package.json"), - "{ \"packageManager\": \"yarn@4.1.1+sha224.abcdef\" }"); - var result = _detector.Detect(_tempDir); - Assert.AreEqual("yarn", result.Name); - } - - [TestMethod] - public void Detect_CorepackUnknownPM_FallsThroughToLockfile() - { - // Future PMs we haven't heard of should not crash detection; we fall - // through to the lockfile sniffing layer instead. - File.WriteAllText(Path.Combine(_tempDir.FullName, "yarn.lock"), "# yarn lockfile v1\n"); - File.WriteAllText( - Path.Combine(_tempDir.FullName, "package.json"), - "{ \"packageManager\": \"futurepm@1.0.0\" }"); - var result = _detector.Detect(_tempDir); - Assert.AreEqual("yarn", result.Name); - } - - [TestMethod] - public void Detect_MalformedPackageJson_FallsBack() - { - // Detection must not crash if package.json is invalid JSON. - File.WriteAllText(Path.Combine(_tempDir.FullName, "package.json"), "not valid json{"); - var result = _detector.Detect(_tempDir); - Assert.AreEqual("npm", result.Name); - } - - [TestMethod] - public void Detect_NullArg_Throws() - { - Assert.ThrowsExactly(() => _detector.Detect(null!)); - } -} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/PathSafetyTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/PathSafetyTests.cs index 1d4ec606..0f4b0555 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/PathSafetyTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/PathSafetyTests.cs @@ -7,8 +7,8 @@ namespace WinApp.Cli.Tests; // Direct tests for the shared PathSafety helper. Pre-r3 the helper was -// only covered indirectly (through ConfigService / UserPackageJsonService / -// WinmdDiscovery), which made it easy for the helper to drift: e.g. the +// only covered indirectly (through ConfigService / WinmdsLockfileService), +// which made it easy for the helper to drift: e.g. the // pre-r3 implementation used FileInfo.Exists internally, which probes the // filesystem before the reparse-point flag check — defeating the helper's // stated purpose. These tests pin the API directly so future edits can't @@ -136,7 +136,7 @@ public void HasReparsePointOnPath_LongPathUncPrefix_ReturnsTrue() [TestMethod] public void HasReparsePointOnPath_MissingLeaf_IsAllowed() { - // Callers (e.g. ConfigService.SaveJsBindingsOnly) check the path + // Callers (e.g. ConfigService.Save) check the path // BEFORE creating the file — a missing leaf must not be rejected. var leaf = Path.Combine(_tempDir.FullName, "not-yet-written.yaml"); Assert.IsFalse(File.Exists(leaf)); diff --git a/src/winapp-CLI/WinApp.Cli.Tests/TestDoubles/FakeDynWinrtCodegenService.cs b/src/winapp-CLI/WinApp.Cli.Tests/TestDoubles/FakeDynWinrtCodegenService.cs deleted file mode 100644 index 7a46e9c0..00000000 --- a/src/winapp-CLI/WinApp.Cli.Tests/TestDoubles/FakeDynWinrtCodegenService.cs +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using WinApp.Cli.ConsoleTasks; -using WinApp.Cli.Models; -using WinApp.Cli.Services; - -namespace WinApp.Cli.Tests.TestDoubles; - -// In-memory IDynWinrtCodegenService — orchestration tests use it instead -// of spawning the real codegen binary. -internal sealed class FakeDynWinrtCodegenService : IDynWinrtCodegenService -{ - public List Calls { get; } = new(); - - // When non-null, RunAsync throws after recording the call. - public Exception? FailWith { get; set; } - - // Stub files written into the output dir on success. - public IReadOnlyDictionary StubFilesPerCall { get; set; } - = new Dictionary { ["index.js"] = "// fake codegen output" }; - - public Task RunAsync( - JsBindingsConfig config, - IReadOnlyList winmds, - FileInfo? windowsSdkWinmd, - DirectoryInfo workspaceDir, - DirectoryInfo winappDir, - TaskContext taskContext, - IReadOnlyList? userAdditionalWinmds = null, - IReadOnlyList? userAdditionalRefs = null, - CancellationToken cancellationToken = default) - { - Calls.Add(new CallRecord - { - Config = config, - EmitWinmds = winmds.Select(f => f.FullName).ToArray(), - UserAdditionalWinmds = (userAdditionalWinmds ?? Array.Empty()).Select(f => f.FullName).ToArray(), - UserAdditionalRefs = (userAdditionalRefs ?? Array.Empty()).Select(f => f.FullName).ToArray(), - WorkspaceDir = workspaceDir.FullName, - WinappDir = winappDir.FullName, - }); - - if (FailWith is not null) - { - throw FailWith; - } - - // Mirror the real success contract: output dir + stub files + marker. - var outputDir = DynWinrtCodegenService.ResolveOutputDir(workspaceDir, config.Output); - outputDir.Create(); - foreach (var (relPath, content) in StubFilesPerCall) - { - var fullPath = Path.Combine(outputDir.FullName, relPath); - Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); - File.WriteAllText(fullPath, content); - } - File.WriteAllText( - Path.Combine(outputDir.FullName, DynWinrtCodegenService.ManagedMarkerFileName), - "# fake managed marker\n"); - return Task.FromResult(outputDir); - } - - public sealed class CallRecord - { - public JsBindingsConfig Config { get; set; } = null!; - public string[] EmitWinmds { get; set; } = Array.Empty(); - public string[] UserAdditionalWinmds { get; set; } = Array.Empty(); - public string[] UserAdditionalRefs { get; set; } = Array.Empty(); - public string WorkspaceDir { get; set; } = string.Empty; - public string WinappDir { get; set; } = string.Empty; - } -} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/TestDoubles/FakeJsBindingsWorkspaceService.cs b/src/winapp-CLI/WinApp.Cli.Tests/TestDoubles/FakeJsBindingsWorkspaceService.cs deleted file mode 100644 index f30de886..00000000 --- a/src/winapp-CLI/WinApp.Cli.Tests/TestDoubles/FakeJsBindingsWorkspaceService.cs +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using WinApp.Cli.ConsoleTasks; -using WinApp.Cli.Services; - -namespace WinApp.Cli.Tests.TestDoubles; - -// Recording fake for the init/restore JS-bindings exec path. Lets tests -// drive WorkspaceSetupService → JsBindingsWorkspaceService.RunAsync without -// spawning real codegen or seeding NuGet caches. -internal sealed class FakeJsBindingsWorkspaceService : IJsBindingsWorkspaceService -{ - public List Calls { get; } = new(); - - // When set, RunAsync returns this result instead of a default success. - public JsBindingsOrchestrationResult? Result { get; set; } - - public Task RunAsync( - JsBindingsOrchestrationContext context, - TaskContext taskContext, - CancellationToken cancellationToken = default) - { - Calls.Add(context); - return Task.FromResult(Result ?? new JsBindingsOrchestrationResult - { - ExitCode = 0, - Message = "fake codegen success", - OutputDir = context.WorkspaceDir, - }); - } - - public bool EnsureRuntimeDependencyCalled { get; private set; } - public void EnsureRuntimeDependencyAndPrintHint(DirectoryInfo workspaceDirectory) - { - EnsureRuntimeDependencyCalled = true; - } -} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UserPackageJsonServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/UserPackageJsonServiceTests.cs deleted file mode 100644 index 7fb31fc2..00000000 --- a/src/winapp-CLI/WinApp.Cli.Tests/UserPackageJsonServiceTests.cs +++ /dev/null @@ -1,327 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using System.Text.Json; -using WinApp.Cli.Services; - -namespace WinApp.Cli.Tests; - -// Unit tests for UserPackageJsonService. Covers each -// RuntimeDependencyOutcome branch plus formatting/ordering -// preservation guarantees. -[TestClass] -public class UserPackageJsonServiceTests -{ - private DirectoryInfo _tempDir = null!; - private UserPackageJsonService _service = null!; - - [TestInitialize] - public void Setup() - { - _tempDir = new DirectoryInfo( - Path.Combine(Path.GetTempPath(), $"UserPkgJsonTests_{Guid.NewGuid():N}")); - _tempDir.Create(); - _service = new UserPackageJsonService(); - } - - [TestCleanup] - public void Teardown() - { - try { _tempDir.Delete(true); } catch { /* ignore */ } - } - - private string PackageJsonPath => Path.Combine(_tempDir.FullName, "package.json"); - - [TestMethod] - public void EnsureRuntimeDependency_NoPackageJson_ReturnsNoPackageJson() - { - var outcome = _service.EnsureRuntimeDependency( - _tempDir, "@microsoft/dynwinrt", "1.0.0"); - Assert.AreEqual(RuntimeDependencyOutcome.NoPackageJson, outcome); - Assert.IsFalse(File.Exists(PackageJsonPath), - "We must not synthesize a package.json on the user's behalf"); - } - - [TestMethod] - public void EnsureRuntimeDependency_NoDependenciesObject_AddsAndReturnsAdded() - { - File.WriteAllText(PackageJsonPath, - "{\n \"name\": \"my-app\",\n \"version\": \"1.0.0\"\n}\n"); - - var outcome = _service.EnsureRuntimeDependency( - _tempDir, "@microsoft/dynwinrt", "1.0.0"); - - Assert.AreEqual(RuntimeDependencyOutcome.Added, outcome); - var content = File.ReadAllText(PackageJsonPath); - using var doc = JsonDocument.Parse(content); - var root = doc.RootElement; - Assert.AreEqual("my-app", root.GetProperty("name").GetString()); - Assert.AreEqual("1.0.0", - root.GetProperty("dependencies").GetProperty("@microsoft/dynwinrt").GetString()); - } - - [TestMethod] - public void EnsureRuntimeDependency_DependenciesExistsButMissingPackage_AddsAndReturnsAdded() - { - File.WriteAllText(PackageJsonPath, - "{\n \"name\": \"my-app\",\n \"dependencies\": {\n \"react\": \"19.0.0\"\n }\n}\n"); - - var outcome = _service.EnsureRuntimeDependency( - _tempDir, "@microsoft/dynwinrt", "1.0.0"); - - Assert.AreEqual(RuntimeDependencyOutcome.Added, outcome); - using var doc = JsonDocument.Parse(File.ReadAllText(PackageJsonPath)); - var deps = doc.RootElement.GetProperty("dependencies"); - Assert.AreEqual("19.0.0", deps.GetProperty("react").GetString(), - "Pre-existing deps must survive untouched"); - Assert.AreEqual("1.0.0", deps.GetProperty("@microsoft/dynwinrt").GetString()); - } - - [TestMethod] - public void EnsureRuntimeDependency_AlreadyInDependencies_NoOpReturnsAlreadyPresent() - { - File.WriteAllText(PackageJsonPath, - "{\n \"dependencies\": {\n \"@microsoft/dynwinrt\": \"0.5.0\"\n }\n}\n"); - var beforeMtime = File.GetLastWriteTimeUtc(PackageJsonPath); - - // Sleep to ensure mtime granularity reveals any unintended write. - Thread.Sleep(50); - - var outcome = _service.EnsureRuntimeDependency( - _tempDir, "@microsoft/dynwinrt", "1.0.0"); - - Assert.AreEqual(RuntimeDependencyOutcome.AlreadyPresent, outcome); - // We must not overwrite the user's pinned version. - using var doc = JsonDocument.Parse(File.ReadAllText(PackageJsonPath)); - Assert.AreEqual("0.5.0", - doc.RootElement.GetProperty("dependencies").GetProperty("@microsoft/dynwinrt").GetString()); - Assert.AreEqual(beforeMtime, File.GetLastWriteTimeUtc(PackageJsonPath), - "AlreadyPresent must not rewrite the file"); - } - - [TestMethod] - public void EnsureRuntimeDependency_OnlyInDevDependencies_ReturnsPresentInDev() - { - File.WriteAllText(PackageJsonPath, - "{\n \"devDependencies\": {\n \"@microsoft/dynwinrt\": \"0.5.0\"\n }\n}\n"); - var beforeMtime = File.GetLastWriteTimeUtc(PackageJsonPath); - Thread.Sleep(50); - - var outcome = _service.EnsureRuntimeDependency( - _tempDir, "@microsoft/dynwinrt", "1.0.0"); - - Assert.AreEqual(RuntimeDependencyOutcome.PresentInDevDependencies, outcome); - Assert.AreEqual(beforeMtime, File.GetLastWriteTimeUtc(PackageJsonPath), - "PresentInDevDependencies must not auto-promote (don't surprise the user)"); - } - - [TestMethod] - public void EnsureRuntimeDependency_PreservesUnrelatedKeysAndOrder() - { - File.WriteAllText(PackageJsonPath, - "{\n" + - " \"name\": \"my-app\",\n" + - " \"version\": \"2.3.4\",\n" + - " \"scripts\": { \"start\": \"node .\" },\n" + - " \"author\": \"alice\"\n" + - "}\n"); - - _service.EnsureRuntimeDependency(_tempDir, "@microsoft/dynwinrt", "1.0.0"); - - var content = File.ReadAllText(PackageJsonPath); - using var doc = JsonDocument.Parse(content); - var root = doc.RootElement; - - // Walk properties in their actual order. - var keysInOrder = root.EnumerateObject().Select(p => p.Name).ToList(); - // dependencies should appear right after version (matching the - // conventional layout); other keys should keep their relative order. - var versionIndex = keysInOrder.IndexOf("version"); - var depsIndex = keysInOrder.IndexOf("dependencies"); - Assert.IsTrue(versionIndex >= 0 && depsIndex == versionIndex + 1, - $"Expected dependencies right after version; got: [{string.Join(", ", keysInOrder)}]"); - - // Author still present (not lost during rebuild). - Assert.AreEqual("alice", root.GetProperty("author").GetString()); - } - - [TestMethod] - public void EnsureRuntimeDependency_PreservesTrailingNewline() - { - File.WriteAllText(PackageJsonPath, "{\n \"name\": \"my-app\"\n}\n"); - _service.EnsureRuntimeDependency(_tempDir, "@microsoft/dynwinrt", "1.0.0"); - var content = File.ReadAllText(PackageJsonPath); - Assert.IsTrue(content.EndsWith('\n'), - "POSIX text file convention: trailing newline must be preserved"); - } - - [TestMethod] - public void EnsureRuntimeDependency_NoTrailingNewline_DoesNotAddOne() - { - File.WriteAllText(PackageJsonPath, "{\"name\":\"my-app\"}"); - _service.EnsureRuntimeDependency(_tempDir, "@microsoft/dynwinrt", "1.0.0"); - var content = File.ReadAllText(PackageJsonPath); - Assert.IsFalse(content.EndsWith('\n'), - "Should preserve original trailing-newline state (none → none)"); - } - - [TestMethod] - public void EnsureRuntimeDependency_MalformedJson_Throws() - { - File.WriteAllText(PackageJsonPath, "not valid json{"); - Assert.ThrowsExactly(() => - _service.EnsureRuntimeDependency(_tempDir, "@microsoft/dynwinrt", "1.0.0")); - } - - [TestMethod] - public void EnsureRuntimeDependency_RootIsNotObject_Throws() - { - File.WriteAllText(PackageJsonPath, "[1, 2, 3]"); - Assert.ThrowsExactly(() => - _service.EnsureRuntimeDependency(_tempDir, "@microsoft/dynwinrt", "1.0.0")); - } - - [TestMethod] - public void EnsureRuntimeDependency_NullOrEmptyArgs_Throws() - { - Assert.ThrowsExactly(() => - _service.EnsureRuntimeDependency(null!, "x", "1.0.0")); - Assert.ThrowsExactly(() => - _service.EnsureRuntimeDependency(_tempDir, "", "1.0.0")); - Assert.ThrowsExactly(() => - _service.EnsureRuntimeDependency(_tempDir, "x", "")); - } - - // ---- Reparse-point guard (M9) ---- - - [TestMethod] - public void EnsureRuntimeDependency_PackageJsonIsSymlink_Throws() - { - // Plant a real package.json elsewhere, then symlink it into the - // workspace. The guard must refuse to rewrite via the symlink so a - // malicious workspace can't redirect the edit to a victim file. - var realDir = new DirectoryInfo( - Path.Combine(Path.GetTempPath(), $"UserPkgJsonTests_Real_{Guid.NewGuid():N}")); - realDir.Create(); - try - { - var realPackageJson = Path.Combine(realDir.FullName, "package.json"); - File.WriteAllText(realPackageJson, "{\"name\":\"victim\"}"); - - try - { - File.CreateSymbolicLink(PackageJsonPath, realPackageJson); - } - catch (Exception ex) when (ex is UnauthorizedAccessException or IOException) - { - // Creating a symlink on Windows requires admin or Developer - // Mode. Skip this assertion silently rather than fail the - // suite on locked-down CI/dev machines. - Assert.Inconclusive($"Could not create a symbolic link in this environment: {ex.Message}"); - return; - } - - var ex2 = Assert.ThrowsExactly(() => - _service.EnsureRuntimeDependency(_tempDir, "@microsoft/dynwinrt", "1.0.0")); - StringAssert.Contains(ex2.Message, "symbolic link", "Error must explain the refusal"); - // Real file must be untouched. - Assert.AreEqual("{\"name\":\"victim\"}", File.ReadAllText(realPackageJson)); - } - finally - { - try { realDir.Delete(true); } catch { /* ignore */ } - } - } - - [TestMethod] - public void EnsureRuntimeDependency_AncestorIsJunction_Throws() - { - // Same threat as a file-level symlink, but at a directory ancestor: - // `\\nested\` is a junction pointing at a real dir - // that holds a package.json. Refusing must cover this case too. - var realDir = new DirectoryInfo( - Path.Combine(Path.GetTempPath(), $"UserPkgJsonTests_RealDir_{Guid.NewGuid():N}")); - realDir.Create(); - try - { - File.WriteAllText(Path.Combine(realDir.FullName, "package.json"), "{\"name\":\"victim\"}"); - - var junctionPath = Path.Combine(_tempDir.FullName, "nested"); - try - { - // mklink /J is non-elevating on Windows even without Dev Mode. - Directory.CreateSymbolicLink(junctionPath, realDir.FullName); - } - catch (Exception ex) when (ex is UnauthorizedAccessException or IOException) - { - Assert.Inconclusive($"Could not create a directory link in this environment: {ex.Message}"); - return; - } - - var junctionWorkspace = new DirectoryInfo(junctionPath); - var ex2 = Assert.ThrowsExactly(() => - _service.EnsureRuntimeDependency(junctionWorkspace, "@microsoft/dynwinrt", "1.0.0")); - StringAssert.Contains(ex2.Message, "symbolic link", "Error must explain the refusal"); - } - finally - { - try { realDir.Delete(true); } catch { /* ignore */ } - } - } - - [TestMethod] - public void EnsureRuntimeDependency_LockedPackageJson_ThrowsWrapped() - { - File.WriteAllText(PackageJsonPath, "{\"name\":\"my-app\",\"version\":\"1.0.0\"}"); - // Hold an exclusive lock so the service's atomic write - // (or its preceding read) fails. The wrapper must surface this as - // an InvalidOperationException, not a raw IOException — otherwise - // CLI orchestration aborts mid-init instead of degrading to a - // warning. - using var locker = new FileStream( - PackageJsonPath, FileMode.Open, FileAccess.Read, FileShare.None); - var ex = Assert.ThrowsExactly(() => - _service.EnsureRuntimeDependency(_tempDir, "@microsoft/dynwinrt", "1.0.0")); - Assert.IsNotNull(ex.InnerException); - Assert.IsTrue( - ex.InnerException is IOException or UnauthorizedAccessException, - $"Inner exception should be IOException or UnauthorizedAccessException, was {ex.InnerException?.GetType().Name}"); - } - - // --------------------------------------------------------------------- - // L2 — write-path catch reachable when destination can be READ but - // cannot be REPLACED. Pre-existing LockedPackageJson_ThrowsWrapped uses - // FileShare.None which lights up the READ catch path; this test holds - // the destination open for FileShare.Read (so the service's read - // succeeds) and asserts the WRITE catch wraps the rename failure with - // the actionable "Failed to write" prefix. - // --------------------------------------------------------------------- - - [TestMethod] - public void EnsureRuntimeDependency_DestinationWriteLocked_WrapsWithFailedToWrite() - { - File.WriteAllText(PackageJsonPath, "{\"name\":\"my-app\",\"version\":\"1.0.0\"}"); - - // Open WITH FileShare.Read: other readers (the service's - // File.OpenRead / File.ReadAllText) succeed, but File.Move - // overwriting the destination fails because no FileShare.Write - // is granted. That lands in the catch at lines 120-128 of - // UserPackageJsonService. - using var locker = new FileStream( - PackageJsonPath, - FileMode.Open, - FileAccess.Read, - FileShare.Read); - - var ex = Assert.ThrowsExactly(() => - _service.EnsureRuntimeDependency(_tempDir, "@microsoft/dynwinrt", "1.0.0")); - - StringAssert.Matches( - ex.Message, - new System.Text.RegularExpressions.Regex("(Failed|No permission) to write"), - "Wrapper must surface the I/O / permission failure with an actionable 'to write' prefix."); - Assert.IsTrue( - ex.InnerException is IOException or UnauthorizedAccessException, - $"Inner must be IOException / UnauthorizedAccessException, was {ex.InnerException?.GetType().Name}"); - } -} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/WinappConfigDocumentTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/WinappConfigDocumentTests.cs deleted file mode 100644 index d858ee31..00000000 --- a/src/winapp-CLI/WinApp.Cli.Tests/WinappConfigDocumentTests.cs +++ /dev/null @@ -1,497 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using WinApp.Cli.Models; -using WinApp.Cli.Services; - -// CA1861 ("avoid constant arrays as arguments") is real perf advice in hot -// paths, but these tests use one-shot literal arrays as fixture data and -// extracting each to a `static readonly` field would make the round-trip -// cases noticeably harder to read. Suppress at file scope. -#pragma warning disable CA1861 - -namespace WinApp.Cli.Tests; - -// Direct round-trip tests for the WinappConfigDocument YAML grammar. -// Pre-r3 the document type was only exercised indirectly through -// ConfigService, which made it easy for the parser and the renderer to -// drift apart silently. These tests pin Render → Parse fidelity on the -// awkward inputs (single-quote escaping, `#` inside values, extraTypes -// key ordering) that the round-3 review surfaced as silent data loss. -[TestClass] -public class WinappConfigDocumentTests -{ - private static WinappConfig RoundTrip(WinappConfig cfg) - { - var yaml = new WinappConfigDocument(cfg).Render(); - return WinappConfigDocument.Parse(yaml).Config; - } - - // --------------------------------------------------------------------- - // M10 — single-quoted scalar escaping - // --------------------------------------------------------------------- - - [TestMethod] - public void RoundTrip_OutputContainingApostrophe_PreservesValue() - { - // YAML single-quoted scalars escape a literal `'` as `''`. The - // renderer used to emit the doubled form correctly, but the parser - // didn't un-double on read — every save grew the apostrophe count. - var cfg = new WinappConfig - { - JsBindings = new JsBindingsConfig - { - Lang = "js", - Output = "bindings/O'Brien", - }, - }; - - var rt = RoundTrip(cfg); - - Assert.IsNotNull(rt.JsBindings); - Assert.AreEqual("bindings/O'Brien", rt.JsBindings!.Output); - } - - [TestMethod] - public void RoundTrip_OutputContainingHashChar_PreservesValue() - { - // An unquoted `#` introduces a comment; the renderer must quote and - // the parser must NOT strip the `#` from the quoted value. - var cfg = new WinappConfig - { - JsBindings = new JsBindingsConfig - { - Lang = "js", - Output = "bindings/c#-output", - }, - }; - - var rt = RoundTrip(cfg); - - Assert.AreEqual("bindings/c#-output", rt.JsBindings!.Output); - } - - [TestMethod] - public void RoundTrip_PackageNameContainingApostrophe_PreservesValue() - { - // packages: list items go through the same QuoteScalar/SanitizeScalar - // pipeline; cover that path explicitly. - var cfg = new WinappConfig - { - JsBindings = new JsBindingsConfig - { - Lang = "js", - Output = "bindings/winrt", - Packages = { "Some.Vendor's.Package" }, - }, - }; - - var rt = RoundTrip(cfg); - - CollectionAssert.AreEqual( - new[] { "Some.Vendor's.Package" }, - rt.JsBindings!.Packages); - } - - // --------------------------------------------------------------------- - // M8 — extraTypes parser must be key-order-independent - // --------------------------------------------------------------------- - - [TestMethod] - public void Parse_ExtraTypesWithClassesBeforeNamespace_DoesNotDropEntry() - { - // A user (or a YAML formatter that alphabetises keys) may write - // `classes:` before `namespace:`. Pre-r3 the parser required the - // dash line to be `- namespace:` exactly and silently dropped - // entries that led with `- classes:`. - var yaml = string.Join('\n', new[] - { - "packages: []", - "jsBindings:", - " lang: js", - " output: bindings/winrt", - " extraTypes:", - " - classes:", - " - SomeClass", - " namespace: My.Namespace", - "", - }); - - var cfg = WinappConfigDocument.Parse(yaml).Config; - - Assert.IsNotNull(cfg.JsBindings); - Assert.AreEqual(1, cfg.JsBindings!.ExtraTypes.Count, "classes-first entry must NOT be dropped"); - var entry = cfg.JsBindings.ExtraTypes[0]; - Assert.AreEqual("My.Namespace", entry.Namespace); - CollectionAssert.AreEqual(new[] { "SomeClass" }, entry.Classes); - } - - [TestMethod] - public void Parse_ExtraTypesMultipleEntries_BothOrderingsCoexist() - { - var yaml = string.Join('\n', new[] - { - "packages: []", - "jsBindings:", - " lang: js", - " output: bindings/winrt", - " extraTypes:", - " - namespace: First.Ns", - " classes:", - " - First.A", - " - First.B", - " - classes:", - " - Second.X", - " namespace: Second.Ns", - "", - }); - - var cfg = WinappConfigDocument.Parse(yaml).Config; - - Assert.AreEqual(2, cfg.JsBindings!.ExtraTypes.Count); - Assert.AreEqual("First.Ns", cfg.JsBindings.ExtraTypes[0].Namespace); - CollectionAssert.AreEqual( - new[] { "First.A", "First.B" }, - cfg.JsBindings.ExtraTypes[0].Classes); - Assert.AreEqual("Second.Ns", cfg.JsBindings.ExtraTypes[1].Namespace); - CollectionAssert.AreEqual( - new[] { "Second.X" }, - cfg.JsBindings.ExtraTypes[1].Classes); - } - - [TestMethod] - public void RoundTrip_ExtraTypesWithApostropheInClassName_Preserved() - { - // Cover the QuoteScalar path for extraTypes entries too — both - // namespace and classes go through it. - var cfg = new WinappConfig - { - JsBindings = new JsBindingsConfig - { - Lang = "js", - Output = "bindings/winrt", - ExtraTypes = - { - new JsBindingsExtraType - { - Namespace = "Vendor's.Namespace", - Classes = { "Vendor's.Class" }, - }, - }, - }, - }; - - var rt = RoundTrip(cfg); - - Assert.AreEqual(1, rt.JsBindings!.ExtraTypes.Count); - Assert.AreEqual("Vendor's.Namespace", rt.JsBindings.ExtraTypes[0].Namespace); - CollectionAssert.AreEqual( - new[] { "Vendor's.Class" }, - rt.JsBindings.ExtraTypes[0].Classes); - } - - // --------------------------------------------------------------------- - // QuoteScalar coverage — values the renderer MUST quote - // --------------------------------------------------------------------- - - [TestMethod] - public void RoundTrip_WindowsPathWithDriveColon_PreservedAsString() - { - // `C:\foo` contains a `:` so the renderer must quote — otherwise - // the next load would re-parse it as a mapping. - var cfg = new WinappConfig - { - JsBindings = new JsBindingsConfig - { - Lang = "js", - Output = "bindings/winrt", - AdditionalWinmds = { @"C:\winmds\extra.winmd" }, - }, - }; - - var rt = RoundTrip(cfg); - - CollectionAssert.AreEqual( - new[] { @"C:\winmds\extra.winmd" }, - rt.JsBindings!.AdditionalWinmds); - } - - [TestMethod] - public void RoundTrip_ReservedYamlBooleanLikeValue_PreservedAsString() - { - // A package id like `no` (unlikely but legal) would be re-parsed - // as the YAML 1.1 boolean false; the renderer must quote. - var cfg = new WinappConfig - { - JsBindings = new JsBindingsConfig - { - Lang = "js", - Output = "bindings/winrt", - Packages = { "no" }, - }, - }; - - var rt = RoundTrip(cfg); - - CollectionAssert.AreEqual(new[] { "no" }, rt.JsBindings!.Packages); - } - - [TestMethod] - public void RoundTrip_ValueLeadingWithDash_PreservedAsString() - { - // A leading `-` would otherwise be parsed as a YAML list marker. - var cfg = new WinappConfig - { - JsBindings = new JsBindingsConfig - { - Lang = "js", - Output = "-leading-dash-dir/winrt", - }, - }; - - var rt = RoundTrip(cfg); - - Assert.AreEqual("-leading-dash-dir/winrt", rt.JsBindings!.Output); - } - - // --------------------------------------------------------------------- - // M7 — `packages:` must accept inline comments / trailing whitespace - // --------------------------------------------------------------------- - - [TestMethod] - public void Parse_PackagesHeaderWithInlineComment_StillCollectsEntries() - { - // Pre-r4 the parser required `t.Equals("packages:")` exactly, so - // a comment on the header line (`packages: # SDK pins`) silently - // reset the section to None and every subsequent `- name:` / - // `version:` line was dropped. `restore` then loaded zero - // packages and did nothing. Fixed by routing through IsTopLevelKey - // (the same comment-tolerant detector that `jsBindings:` uses). - var yaml = string.Join('\n', new[] - { - "packages: # SDK pins", - " - name: Microsoft.WindowsAppSDK", - " version: 1.8.39", - "", - }); - - var cfg = WinappConfigDocument.Parse(yaml).Config; - - Assert.AreEqual(1, cfg.Packages.Count, - "packages: with inline comment must still collect entries"); - Assert.AreEqual("Microsoft.WindowsAppSDK", cfg.Packages[0].Name); - Assert.AreEqual("1.8.39", cfg.Packages[0].Version); - } - - // r5-F1 regression — nested jsBindings sub-keys had the SAME exact-string - // equality bug as the top-level packages: header. An inline comment on - // any of additionalWinmds: / packages: / extraTypes: / classes: silently - // mis-routed the following list items into the previously-active list. - [TestMethod] - public void Parse_NestedAdditionalWinmdsHeaderWithInlineComment_DoesNotMisrouteEntries() - { - var yaml = string.Join('\n', new[] - { - "jsBindings:", - " lang: js", - " packages:", - " - Microsoft.WindowsAppSDK", - " additionalWinmds: # vendor SDKs go here", - " - vendor/Foo.winmd", - " additionalRefs: # ref-only WinMDs", - " - vendor/Bar.winmd", - "", - }); - - var cfg = WinappConfigDocument.Parse(yaml).Config; - - Assert.IsNotNull(cfg.JsBindings); - // Pre-fix: `vendor/Foo.winmd` would have stayed appended to - // js.Packages because additionalWinmds: with inline comment was - // missed; listMode stayed Packages. - CollectionAssert.AreEqual( - new[] { "Microsoft.WindowsAppSDK" }, - cfg.JsBindings!.Packages.ToList(), - "Packages must not absorb additionalWinmds entries after inline-comment header."); - CollectionAssert.AreEqual( - new[] { "vendor/Foo.winmd" }, - cfg.JsBindings.AdditionalWinmds.ToList(), - "additionalWinmds: header with inline comment must open the AdditionalWinmds list."); - CollectionAssert.AreEqual( - new[] { "vendor/Bar.winmd" }, - cfg.JsBindings.AdditionalRefs.ToList(), - "additionalRefs: header with inline comment must open the AdditionalRefs list."); - } - - [TestMethod] - public void Parse_NestedClassesHeaderWithInlineComment_OpensClassesListNotInlineScalar() - { - // For extraTypes[].classes:, the parser had two branches: - // 1. `t.Equals("classes:")` → open the classes list - // 2. `t.StartsWith("classes:")` → try to parse inline `[X,Y]` form - // With `classes: # comment` only branch 2 matched pre-fix; rest was - // `# comment` which is not `[…]`, so it silently fell through and - // the subsequent `- ClassName` lines were dropped. - var yaml = string.Join('\n', new[] - { - "jsBindings:", - " extraTypes:", - " - namespace: Windows.Foundation", - " classes: # only these types are emitted", - " - Uri", - " - PropertyValue", - "", - }); - - var cfg = WinappConfigDocument.Parse(yaml).Config; - - Assert.IsNotNull(cfg.JsBindings); - Assert.AreEqual(1, cfg.JsBindings!.ExtraTypes.Count); - CollectionAssert.AreEqual( - new[] { "Uri", "PropertyValue" }, - cfg.JsBindings.ExtraTypes[0].Classes.ToList(), - "classes: header with inline comment must open the classes list."); - } - - // --------------------------------------------------------------------- - // L1 — plain scalar with apostrophe + inline comment round-trip - // --------------------------------------------------------------------- - - [TestMethod] - public void Parse_PlainScalarApostropheWithInlineComment_StripsCommentNotApostrophe() - { - // A plain (unquoted) scalar like `output: foo's-dir # comment` - // must drop the ` # comment` suffix. Pre-r4 SanitizeScalar - // toggled inSingle on the apostrophe and then treated the `#` - // as "inside a single-quoted scalar", so the comment leaked into - // the value and a subsequent save re-quoted the whole thing. - var yaml = string.Join('\n', new[] - { - "jsBindings:", - " lang: js", - " output: foo's-dir # this is a comment, drop me", - "", - }); - - var cfg = WinappConfigDocument.Parse(yaml).Config; - - Assert.IsNotNull(cfg.JsBindings); - Assert.AreEqual("foo's-dir", cfg.JsBindings!.Output, - "plain-scalar apostrophe must NOT suppress inline-comment stripping"); - } - - // --------------------------------------------------------------------- - // M8 — SpliceJsBindingsInto contract (preserve comments / unknowns; - // honor null = remove; append when missing) - // --------------------------------------------------------------------- - - [TestMethod] - public void SpliceJsBindingsInto_PreservesLeadingCommentAndTrailingSections() - { - // The splice must rewrite only the jsBindings: block in place - // and leave every other byte of the existing yaml verbatim — - // including a leading comment line and the trailing packages: - // section. ConfigService.SaveJsBindingsOnly is the production - // caller; if this drifts, user-authored YAML loses comments - // every time the JS bindings step runs. - var existing = string.Join('\n', new[] - { - "# user-managed file — do not edit jsBindings by hand", - "", - "jsBindings:", - " lang: ts", - " output: bindings/old", - "", - "packages:", - " - name: Microsoft.WindowsAppSDK", - " version: 1.8.39", - "", - }); - - var doc = new WinappConfigDocument(new WinappConfig - { - JsBindings = new JsBindingsConfig - { - Lang = "js", - Output = "bindings/new", - }, - }); - - var spliced = doc.SpliceJsBindingsInto(existing); - - // Leading comment + the entire packages section must survive. - StringAssert.Contains(spliced, "# user-managed file"); - StringAssert.Contains(spliced, "packages:"); - StringAssert.Contains(spliced, "Microsoft.WindowsAppSDK"); - StringAssert.Contains(spliced, "1.8.39"); - // The jsBindings block must reflect the new values. - StringAssert.Contains(spliced, "lang: js"); - StringAssert.Contains(spliced, "bindings/new"); - Assert.IsFalse(spliced.Contains("bindings/old"), - "old jsBindings.output must be gone after splice"); - } - - [TestMethod] - public void SpliceJsBindingsInto_NullJsBindings_RemovesBlockButKeepsRest() - { - // `JsBindings = null` means "remove the block". The rest of the - // file (other sections, comments) must remain untouched so a user - // can revert by hand-deleting their bindings declaration. - var existing = string.Join('\n', new[] - { - "packages:", - " - name: Microsoft.WindowsAppSDK", - " version: 1.8.39", - "", - "jsBindings:", - " lang: js", - " output: bindings/winrt", - "", - }); - - var doc = new WinappConfigDocument(new WinappConfig { JsBindings = null }); - - var spliced = doc.SpliceJsBindingsInto(existing); - - Assert.IsFalse(spliced.Contains("jsBindings:"), - "jsBindings: header must be gone after splice with null JsBindings"); - Assert.IsFalse(spliced.Contains("bindings/winrt"), - "old jsBindings body must be gone"); - StringAssert.Contains(spliced, "packages:"); - StringAssert.Contains(spliced, "Microsoft.WindowsAppSDK"); - } - - [TestMethod] - public void SpliceJsBindingsInto_NoExistingBlock_AppendsOneAndRoundTrips() - { - // When the user's yaml has no jsBindings: yet, splice must - // append one in a way that parses cleanly on the next load. - var existing = string.Join('\n', new[] - { - "packages:", - " - name: Microsoft.WindowsAppSDK", - " version: 1.8.39", - "", - }); - - var doc = new WinappConfigDocument(new WinappConfig - { - JsBindings = new JsBindingsConfig - { - Lang = "js", - Output = "bindings/winrt", - }, - }); - - var spliced = doc.SpliceJsBindingsInto(existing); - - // Existing section is preserved AND the new block parses back. - StringAssert.Contains(spliced, "packages:"); - StringAssert.Contains(spliced, "jsBindings:"); - var roundTripped = WinappConfigDocument.Parse(spliced).Config; - Assert.AreEqual(1, roundTripped.Packages.Count); - Assert.IsNotNull(roundTripped.JsBindings); - Assert.AreEqual("js", roundTripped.JsBindings!.Lang); - Assert.AreEqual("bindings/winrt", roundTripped.JsBindings.Output); - } -} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/WinmdsLockfileServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/WinmdsLockfileServiceTests.cs index 656f695a..1a681db8 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/WinmdsLockfileServiceTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/WinmdsLockfileServiceTests.cs @@ -10,8 +10,6 @@ namespace WinApp.Cli.Tests; [TestClass] public class WinmdsLockfileServiceTests { - private static readonly string[] _arr00 = ["Microsoft.WindowsAppSDK.AI"]; - public TestContext TestContext { get; set; } = null!; private DirectoryInfo _temp = null!; @@ -58,10 +56,11 @@ public async Task WriteAsync_ProducesIndentedSchemaVersionedJson() var path = _svc.GetLockfilePath(winapp); Assert.IsTrue(path.Exists, "Lockfile must be written under .winapp/."); var json = await File.ReadAllTextAsync(path.FullName); - StringAssert.Contains(json, "\"schema\": 2"); + StringAssert.Contains(json, "\"schema\": 3"); StringAssert.Contains(json, "\"generated_at\""); StringAssert.Contains(json, "Microsoft.WindowsAppSDK.AI"); - StringAssert.Contains(json, "\"category\": \"emit\""); + Assert.IsFalse(json.Contains("\"category\""), + "v3 lockfile must NOT emit a `category` field — that classification is owned by the @microsoft/winapp npm wrapper now."); Assert.IsTrue(json.Contains('\n'), "Output must be indented (multiple lines)."); var bytes = await File.ReadAllBytesAsync(path.FullName); Assert.IsFalse( @@ -70,12 +69,12 @@ public async Task WriteAsync_ProducesIndentedSchemaVersionedJson() } [TestMethod] - public async Task RoundTrip_PreservesPackageVersionsAndCategories() + public async Task RoundTrip_PreservesPackageVersionsAndWinmds() { var winapp = _temp.CreateSubdirectory("winapp"); var cache = _temp.CreateSubdirectory("packages"); - // Build a realistic mix: emit + ref-only + skip + a package with zero winmds. + // Realistic mix including a package with zero winmds (the umbrella). var aiWinmd = MakeFile(cache, "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); var ieWinmd = MakeFile(cache, "microsoft.windowsappsdk.interactiveexperiences", "1.8.0", "metadata", "10.0.18362.0", "Microsoft.UI.winmd"); var winuiWinmd = MakeFile(cache, "microsoft.windowsappsdk.winui", "1.8.0", "metadata", "Microsoft.UI.Xaml.winmd"); @@ -91,26 +90,22 @@ public async Task RoundTrip_PreservesPackageVersionsAndCategories() var lockfile = await _svc.TryReadAsync(winapp, default); Assert.IsNotNull(lockfile); - Assert.AreEqual(2, lockfile.Schema); + Assert.AreEqual(3, lockfile.Schema); Assert.AreEqual(4, lockfile.Packages.Count); // Packages are sorted alphabetically (case-insensitive) by name. var ai = lockfile.Packages.Single(p => p.Name == "Microsoft.WindowsAppSDK.AI"); Assert.AreEqual("1.8.39", ai.Version); - Assert.AreEqual("emit", ai.Category); Assert.AreEqual(1, ai.Winmds.Count); Assert.IsTrue(ai.Winmds[0].EndsWith("Microsoft.Windows.AI.winmd", StringComparison.OrdinalIgnoreCase)); var ie = lockfile.Packages.Single(p => p.Name == "Microsoft.WindowsAppSDK.InteractiveExperiences"); - Assert.AreEqual("refOnly", ie.Category); Assert.AreEqual(1, ie.Winmds.Count); var winui = lockfile.Packages.Single(p => p.Name == "Microsoft.WindowsAppSDK.WinUI"); - Assert.AreEqual("skip", winui.Category); Assert.AreEqual(1, winui.Winmds.Count); var umbrella = lockfile.Packages.Single(p => p.Name == "Microsoft.WindowsAppSDK"); - Assert.AreEqual("emit", umbrella.Category); Assert.AreEqual(0, umbrella.Winmds.Count, "Umbrella package has no winmd files; lockfile records it for completeness."); } @@ -161,61 +156,6 @@ public void BuildLockfile_VendorWinmdsOutsideCache_AreDropped() "Vendor winmd outside the NuGet cache must not get attached to any package."); } - [TestMethod] - public void BuildLockfile_PartitionFromLockfile_AppliesScopeAsEmitFilter_DemotesUnscopedToRefOnly() - { - // scope narrows EMIT, not codegen visibility — unscoped Emit - // packages must end up RefOnly for cross-package type resolution. - var cache = _temp.CreateSubdirectory("packages"); - var aiWinmd = MakeFile(cache, "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); - var fdnWinmd = MakeFile(cache, "microsoft.windowsappsdk.foundation", "1.8.0", "metadata", "Microsoft.Windows.Foundation.winmd"); - - var lockfile = WinmdsLockfileService.BuildLockfile( - new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["Microsoft.WindowsAppSDK.AI"] = "1.8.39", - ["Microsoft.WindowsAppSDK.Foundation"] = "1.8.0", - }, - new[] { aiWinmd, fdnWinmd }, - cache); - - var (emit, refOnly, skipped) = JsBindingsWorkspaceService.PartitionFromLockfile( - lockfile, _arr00); - - Assert.AreEqual(1, emit.Count, "Only the scoped AI package emits."); - Assert.IsTrue(emit[0].FullName.EndsWith("Microsoft.Windows.AI.winmd", StringComparison.OrdinalIgnoreCase)); - Assert.AreEqual(1, refOnly.Count, - "Unscoped Foundation package MUST be preserved as RefOnly (it provides types AI references). " - + "An earlier implementation dropped the package entirely → broken codegen."); - Assert.IsTrue(refOnly[0].FullName.EndsWith("Microsoft.Windows.Foundation.winmd", StringComparison.OrdinalIgnoreCase)); - Assert.AreEqual(0, skipped); - } - - [TestMethod] - public void PartitionFromLockfile_NullScope_ReturnsAllPackages() - { - var cache = _temp.CreateSubdirectory("packages"); - var aiWinmd = MakeFile(cache, "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); - var winuiWinmd = MakeFile(cache, "microsoft.windowsappsdk.winui", "1.8.0", "metadata", "Microsoft.UI.Xaml.winmd"); - var ieWinmd = MakeFile(cache, "microsoft.windowsappsdk.interactiveexperiences", "1.8.0", "metadata", "Microsoft.UI.winmd"); - - var lockfile = WinmdsLockfileService.BuildLockfile( - new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["Microsoft.WindowsAppSDK.AI"] = "1.8.39", - ["Microsoft.WindowsAppSDK.WinUI"] = "1.8.0", - ["Microsoft.WindowsAppSDK.InteractiveExperiences"] = "1.8.0", - }, - new[] { aiWinmd, winuiWinmd, ieWinmd }, - cache); - - var (emit, refOnly, skipped) = JsBindingsWorkspaceService.PartitionFromLockfile(lockfile, null); - - Assert.AreEqual(1, emit.Count); - Assert.AreEqual(1, refOnly.Count); - Assert.AreEqual(1, skipped, "WinUI package contributes 1 to the skipped count."); - } - // ------------------------------------------------------------------------- // v2.3 — yaml hash, atomic write, schema-bump back-compat // ------------------------------------------------------------------------- @@ -241,15 +181,18 @@ await _svc.WriteAsync( } [TestMethod] - public async Task TryReadAsync_Schema1Lockfile_ReturnsNull() + public async Task TryReadAsync_OlderSchemaVersions_ReturnNull() { - // pre-v2.3 lockfiles use schema=1; readers treat them as missing - // so the slow path rebuilds. - var path = _svc.GetLockfilePath(_temp); - await File.WriteAllTextAsync(path.FullName, "{\"schema\": 1, \"packages\": []}"); - - var result = await _svc.TryReadAsync(_temp, default); - Assert.IsNull(result, "Schema 1 lockfiles must be ignored after the v2.3 schema bump."); + // Pre-v3 lockfiles (schema 1 or 2) used a Category field that was + // computed by native; v3 readers must ignore them so the npm wrapper + // can force a fresh restore that omits that field. + foreach (var oldSchema in new[] { 1, 2 }) + { + var path = _svc.GetLockfilePath(_temp); + await File.WriteAllTextAsync(path.FullName, $"{{\"schema\": {oldSchema}, \"packages\": []}}"); + var result = await _svc.TryReadAsync(_temp, default); + Assert.IsNull(result, $"Schema {oldSchema} lockfiles must be ignored after the v3 schema bump."); + } } [TestMethod] @@ -276,76 +219,6 @@ await _svc.WriteAsync( $"No tmp staging file should remain after a successful write. Found: {string.Join(", ", entries)}"); } - [TestMethod] - public void PartitionFromLockfile_UncWinmdPaths_DroppedForBothEmitAndRefOnly() - { - // A tampered lockfile smuggling UNC paths must drop them for both - // emit and ref-only — any FileInfo() probe would SMB-handshake. - var cache = _temp.CreateSubdirectory("packages"); - var legitEmit = MakeFile(cache, "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd"); - var legitRef = MakeFile(cache, "microsoft.windowsappsdk.foundation", "1.8.0", "metadata", "Microsoft.Windows.Foundation.winmd"); - - // Hand-build the lockfile (BuildLockfile would reject paths outside - // the cache). RFC 2606 `.invalid` TLD never resolves. - var lockfile = new WinmdsLockfile - { - Schema = WinmdsLockfile.CurrentSchema, - GeneratedAt = DateTimeOffset.UtcNow.ToString("O"), - NugetCacheDir = cache.FullName, - YamlPackagesHash = "h", - Packages = new List - { - new WinmdsLockfilePackage - { - Name = "Microsoft.WindowsAppSDK.AI", - Version = "1.8.39", - Category = "emit", - Winmds = new List - { - legitEmit.FullName, - @"\\nonexistent-attacker.invalid\share\evil.emit.winmd", - }, - }, - new WinmdsLockfilePackage - { - Name = "Microsoft.WindowsAppSDK.Foundation", - Version = "1.8.0", - // Default category is Emit but no scope → demoted to RefOnly. - Category = "emit", - Winmds = new List - { - legitRef.FullName, - @"\\nonexistent-attacker.invalid\share\evil.ref.winmd", - }, - }, - }, - }; - - var sw = System.Diagnostics.Stopwatch.StartNew(); - var (emit, refOnly, _) = JsBindingsWorkspaceService.PartitionFromLockfile( - lockfile, _arr00); - sw.Stop(); - - Assert.IsTrue(sw.ElapsedMilliseconds < 2_000, - $"PartitionFromLockfile must drop UNC paths before any FileInfo probe " - + $"(took {sw.ElapsedMilliseconds}ms; >1s suggests SMB negotiation)."); - - Assert.AreEqual(1, emit.Count, "Only the legit emit winmd survives."); - Assert.IsTrue( - emit[0].FullName.EndsWith("Microsoft.Windows.AI.winmd", StringComparison.OrdinalIgnoreCase), - $"Emit must keep the legit local path. Got: {string.Join(", ", emit.Select(f => f.FullName))}"); - Assert.IsFalse( - emit.Any(f => f.FullName.Contains("nonexistent-attacker.invalid", StringComparison.OrdinalIgnoreCase)), - "Emit MUST drop the UNC entry."); - - Assert.AreEqual(1, refOnly.Count, "Only the legit ref-only winmd survives."); - Assert.IsTrue( - refOnly[0].FullName.EndsWith("Microsoft.Windows.Foundation.winmd", StringComparison.OrdinalIgnoreCase)); - Assert.IsFalse( - refOnly.Any(f => f.FullName.Contains("nonexistent-attacker.invalid", StringComparison.OrdinalIgnoreCase)), - "RefOnly MUST drop the UNC entry."); - } - private static FileInfo MakeFile(DirectoryInfo cache, params string[] segments) { var path = Path.Combine(new[] { cache.FullName }.Concat(segments).ToArray()); @@ -405,7 +278,7 @@ public async Task TryReadAsync_WinappDirIsJunction_ReturnsNullWithoutReading() // way a read could succeed is by traversing the junction. var lockfilePath = Path.Combine(realDir.FullName, "winmds.lock.json"); await File.WriteAllTextAsync(lockfilePath, """ - { "schema": 2, "generated_at": "2025-01-01T00:00:00Z", "entries": [] } + { "schema": 3, "generated_at": "2025-01-01T00:00:00Z", "packages": [] } """); var winappJunction = Path.Combine(_temp.FullName, ".winapp"); diff --git a/src/winapp-CLI/WinApp.Cli.Tests/WorkspaceSetupServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/WorkspaceSetupServiceTests.cs index c97e9d6c..29d09342 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/WorkspaceSetupServiceTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/WorkspaceSetupServiceTests.cs @@ -4,7 +4,6 @@ using Microsoft.Extensions.DependencyInjection; using WinApp.Cli.Models; using WinApp.Cli.Services; -using WinApp.Cli.Tests.TestDoubles; namespace WinApp.Cli.Tests; @@ -989,196 +988,3 @@ public async Task SetupWorkspace_DotNet_InitSucceeds_WhenOptionalWinAppPackageAd #endregion } - -// JS-bindings step propagation tests. Drive -// WorkspaceSetupService.MaybeRunJsBindingsStepAsync directly with a fake -// IJsBindingsWorkspaceService to verify the propagation contract without -// the full restore/install dependency tree. -[TestClass] -public class WorkspaceSetupServiceJsBindingsStepTests : BaseCommandTests -{ - private FakeJsBindingsWorkspaceService _fakeJsBindings = null!; - - protected override IServiceCollection ConfigureServices(IServiceCollection services) - { - _fakeJsBindings = new FakeJsBindingsWorkspaceService(); - var existing = services.FirstOrDefault(d => d.ServiceType == typeof(IJsBindingsWorkspaceService)); - if (existing is not null) - { - services.Remove(existing); - } - services.AddSingleton(_fakeJsBindings); - return services; - } - - private WorkspaceSetupService GetSut() => (WorkspaceSetupService)GetRequiredService(); - - private static WorkspaceSetupOptions MinimalOptions(DirectoryInfo baseDir) => new() - { - BaseDirectory = baseDir, - ConfigDir = baseDir, - SdkInstallMode = SdkInstallMode.None, - }; - - [TestMethod] - public async Task MaybeRunJsBindingsStepAsync_NullConfig_ReturnsNull() - { - // Gate 1: no winapp config → step is a no-op. - var result = await GetSut().MaybeRunJsBindingsStepAsync( - config: null, - usedVersions: new Dictionary(), - nugetCacheDir: _tempDirectory, - localWinappDir: _testWinappDirectory, - options: MinimalOptions(_tempDirectory), - taskContext: TestTaskContext, - cancellationToken: TestContext.CancellationToken); - - Assert.IsNull(result, "Null config must short-circuit the step."); - Assert.AreEqual(0, _fakeJsBindings.Calls.Count, - "RunAsync MUST NOT be invoked when there's no jsBindings config."); - } - - [TestMethod] - public async Task MaybeRunJsBindingsStepAsync_NoJsBindingsBlock_ReturnsNull() - { - // Gate 2: config exists but jsBindings: block is absent → no-op. - var config = new WinappConfig - { - Packages = { new PackagePin { Name = "Microsoft.WindowsAppSDK", Version = "1.8.39" } }, - JsBindings = null, - }; - - var result = await GetSut().MaybeRunJsBindingsStepAsync( - config, - usedVersions: new Dictionary(), - nugetCacheDir: _tempDirectory, - localWinappDir: _testWinappDirectory, - options: MinimalOptions(_tempDirectory), - taskContext: TestTaskContext, - cancellationToken: TestContext.CancellationToken); - - Assert.IsNull(result); - Assert.AreEqual(0, _fakeJsBindings.Calls.Count); - } - - [TestMethod] - public async Task MaybeRunJsBindingsStepAsync_NullUsedVersionsOrDirs_ReturnsNull() - { - // Gates 3/4/5: any of usedVersions / nugetCacheDir / localWinappDir - // null means restore hasn't produced enough state to invoke - // bindings. All three must short-circuit. - var config = new WinappConfig - { - JsBindings = new JsBindingsConfig { Output = "bindings/winrt", Lang = "js" }, - }; - - var sut = GetSut(); - Assert.IsNull(await sut.MaybeRunJsBindingsStepAsync( - config, usedVersions: null, _tempDirectory, _testWinappDirectory, - MinimalOptions(_tempDirectory), TestTaskContext, TestContext.CancellationToken)); - Assert.IsNull(await sut.MaybeRunJsBindingsStepAsync( - config, new Dictionary(), nugetCacheDir: null, _testWinappDirectory, - MinimalOptions(_tempDirectory), TestTaskContext, TestContext.CancellationToken)); - Assert.IsNull(await sut.MaybeRunJsBindingsStepAsync( - config, new Dictionary(), _tempDirectory, localWinappDir: null, - MinimalOptions(_tempDirectory), TestTaskContext, TestContext.CancellationToken)); - - Assert.AreEqual(0, _fakeJsBindings.Calls.Count, - "RunAsync MUST NOT be invoked when any gating dependency is null."); - } - - [TestMethod] - public async Task MaybeRunJsBindingsStepAsync_RunAsyncReturnsZero_ReturnsNull() - { - // Happy path: all gates open, RunAsync reports success → caller - // treats this as a no-op and continues with the rest of init. - _fakeJsBindings.Result = new JsBindingsOrchestrationResult - { - ExitCode = 0, - Message = "ok", - }; - var config = new WinappConfig - { - JsBindings = new JsBindingsConfig { Output = "bindings/winrt", Lang = "js" }, - }; - - var result = await GetSut().MaybeRunJsBindingsStepAsync( - config, - new Dictionary { ["Microsoft.WindowsAppSDK"] = "1.8.39" }, - _tempDirectory, - _testWinappDirectory, - MinimalOptions(_tempDirectory), - TestTaskContext, - TestContext.CancellationToken); - - Assert.IsNull(result, "Success must return null so init proceeds."); - Assert.AreEqual(1, _fakeJsBindings.Calls.Count, "RunAsync must be invoked exactly once."); - } - - [TestMethod] - public async Task MaybeRunJsBindingsStepAsync_RunAsyncReturnsNonZero_PropagatesExitAndMessage() - { - // the core propagation contract. When the - // bindings sub-task fails, init MUST surface the same exit code - // — otherwise the user sees a green "init complete" while their - // bindings dir is empty / partial. - _fakeJsBindings.Result = new JsBindingsOrchestrationResult - { - ExitCode = 7, - Message = "simulated bindings failure", - }; - var config = new WinappConfig - { - JsBindings = new JsBindingsConfig { Output = "bindings/winrt", Lang = "js" }, - }; - - var result = await GetSut().MaybeRunJsBindingsStepAsync( - config, - new Dictionary { ["Microsoft.WindowsAppSDK"] = "1.8.39" }, - _tempDirectory, - _testWinappDirectory, - MinimalOptions(_tempDirectory), - TestTaskContext, - TestContext.CancellationToken); - - Assert.IsNotNull(result, "Failure must return a non-null tuple so caller can propagate."); - Assert.AreEqual(7, result.Value.Item1, - "Exit code from JsBindingsOrchestrationResult.ExitCode must propagate verbatim."); - StringAssert.Contains(result.Value.Item2, "simulated bindings failure", - "Message must propagate so the user can diagnose."); - Assert.AreEqual(1, _fakeJsBindings.Calls.Count); - } - - [TestMethod] - public async Task MaybeRunJsBindingsStepAsync_ForwardsContextFieldsCorrectly() - { - // Regression guard: the context passed to RunAsync must include - // exactly the fields the production SetupWorkspaceAsync passes — - // no field drift between callsite and helper. - _fakeJsBindings.Result = new JsBindingsOrchestrationResult { ExitCode = 0, Message = "ok" }; - var config = new WinappConfig - { - JsBindings = new JsBindingsConfig { Output = "bindings/winrt", Lang = "js" }, - }; - var usedVersions = new Dictionary - { - ["Microsoft.WindowsAppSDK"] = "1.8.39", - ["Microsoft.WindowsAppSDK.AI"] = "1.8.39", - }; - - await GetSut().MaybeRunJsBindingsStepAsync( - config, usedVersions, _tempDirectory, _testWinappDirectory, - MinimalOptions(_tempDirectory), TestTaskContext, TestContext.CancellationToken); - - Assert.AreEqual(1, _fakeJsBindings.Calls.Count); - var captured = _fakeJsBindings.Calls[0]; - Assert.AreSame(config.JsBindings, captured.JsBindingsConfig); - Assert.AreSame(config, captured.WinappConfig); - Assert.AreEqual(_tempDirectory.FullName, captured.WorkspaceDir.FullName); - Assert.AreEqual(_testWinappDirectory.FullName, captured.LocalWinappDir.FullName); - Assert.AreEqual(_tempDirectory.FullName, captured.NugetCacheDir.FullName); - Assert.IsNotNull(captured.UsedVersions); - Assert.AreEqual(2, captured.UsedVersions!.Count); - Assert.AreEqual("1.8.39", captured.UsedVersions["Microsoft.WindowsAppSDK.AI"]); - } -} diff --git a/src/winapp-CLI/WinApp.Cli/Commands/InitCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/InitCommand.cs index 75d7d711..1863319e 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/InitCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/InitCommand.cs @@ -55,7 +55,7 @@ static InitCommand() }; } - public InitCommand() : base("init", "Start here for initializing a Windows app with required setup. Sets up everything needed for Windows app development: creates Package.appxmanifest with default assets, downloads Windows SDK and Windows App SDK packages, and generates projections. When SDK packages are managed (--setup-sdks stable/preview/experimental), also creates winapp.yaml to pin versions for 'restore'/'update'; with --setup-sdks none (e.g., for Rust/Tauri projects that bring their own SDK bindings), no winapp.yaml is created. When invoked via the @microsoft/winappcli npm package (npx winapp init), additionally asks whether to generate C++ projections, JS/TS bindings, or both. Interactive by default (use --use-defaults to skip prompts). Use 'restore' instead if you cloned a repo that already has winapp.yaml. Use 'manifest generate' if you only need a manifest, or 'cert generate' if you need a development certificate for code signing.") + public InitCommand() : base("init", "Start here for initializing a Windows app with required setup. Sets up everything needed for Windows app development: creates Package.appxmanifest with default assets, downloads Windows SDK and Windows App SDK packages, and generates projections. When SDK packages are managed (--setup-sdks stable/preview/experimental), also creates winapp.yaml to pin versions for 'restore'/'update'; with --setup-sdks none (e.g., for Rust/Tauri projects that bring their own SDK bindings), no winapp.yaml is created. Interactive by default (use --use-defaults to skip prompts). Use 'restore' instead if you cloned a repo that already has winapp.yaml. Use 'manifest generate' if you only need a manifest, or 'cert generate' if you need a development certificate for code signing.") { Arguments.Add(BaseDirectoryArgument); Options.Add(ConfigDirOption); diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs b/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs index d75aa22b..5161e9ec 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs @@ -21,11 +21,6 @@ public static IServiceCollection ConfigureServices(this IServiceCollection servi .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/PathSafety.cs b/src/winapp-CLI/WinApp.Cli/Helpers/PathSafety.cs index 1f4908f5..8a4f1eda 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/PathSafety.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/PathSafety.cs @@ -6,8 +6,7 @@ namespace WinApp.Cli.Helpers; // Shared filesystem-safety helpers. Centralizing the reparse-point / -// containment check keeps callers (ConfigService, UserPackageJsonService, -// JsBindingsWorkspaceService.WinmdDiscovery, WinmdsLockfileService) +// containment check keeps callers (ConfigService, WinmdsLockfileService) // consistent — every "write into the user's workspace" site needs the // same guard, and we don't want one to drift behind the others. internal static class PathSafety @@ -114,8 +113,7 @@ public static bool HasReparsePointOnPath(string path, string boundary) // True for UNC / network paths (`\\server\share`, `\\?\UNC\…`, // `\\.\UNC\…`). Local DOS device paths (`\\?\C:\…`) are not network. - // Centralized here so the reparse-point guard and JsBindings winmd - // discovery cannot drift apart — both share the same definition of + // Centralized here so every caller shares the same definition of // "a path that would trigger an SMB probe / NTLM leak". public static bool IsNetworkPath(string path) { diff --git a/src/winapp-CLI/WinApp.Cli/Models/JsBindingsConfig.cs b/src/winapp-CLI/WinApp.Cli/Models/JsBindingsConfig.cs deleted file mode 100644 index f5d8918b..00000000 --- a/src/winapp-CLI/WinApp.Cli/Models/JsBindingsConfig.cs +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -namespace WinApp.Cli.Models; - -// User-facing configuration for JS/TS bindings generated from WinRT metadata. -// Materialized from the optional jsBindings: block in winapp.yaml. -internal sealed class JsBindingsConfig -{ - // Target language. Currently js (default) or py. - public string Lang { get; set; } = "js"; - - // Output directory, relative to the workspace root. - public string Output { get; set; } = "bindings/winrt"; - - // NuGet package IDs to scope binding generation to (empty = all). - public List Packages { get; set; } = new(); - - // Individual classes to generate alongside the bulk pass. - public List ExtraTypes { get; set; } = new(); - - // Extra .winmd files to emit bindings for. - public List AdditionalWinmds { get; set; } = new(); - - // Extra .winmd files loaded for type resolution only. - public List AdditionalRefs { get; set; } = new(); - - // NuGet package IDs to drop entirely. - public List SkipPackages { get; set; } = new(); - - // NuGet package IDs to load as --ref only. - public List RefOnlyPackages { get; set; } = new(); - - // NuGet package IDs to force-emit, overriding skip / ref-only. - public List EmitPackages { get; set; } = new(); -} - -// One namespace + class-name list for selective generation. -internal sealed class JsBindingsExtraType -{ - public string Namespace { get; set; } = string.Empty; - public List Classes { get; set; } = new(); -} diff --git a/src/winapp-CLI/WinApp.Cli/Models/WinappConfig.cs b/src/winapp-CLI/WinApp.Cli/Models/WinappConfig.cs index 359b182a..b3f427fc 100644 --- a/src/winapp-CLI/WinApp.Cli/Models/WinappConfig.cs +++ b/src/winapp-CLI/WinApp.Cli/Models/WinappConfig.cs @@ -7,15 +7,6 @@ internal sealed class WinappConfig { public List Packages { get; set; } = new(); - // Optional JS/TS bindings; when set, restore runs the codegen step. - public JsBindingsConfig? JsBindings { get; set; } - - // Whether to generate C++/WinRT projections (cppwinrt headers + headers/libs/runtimes - // copy). Default true preserves the pre-existing behavior; only `winapp init` writes - // `false` when the npm caller picks "JS only" so pure-Node projects skip ~130MB of - // cppwinrt output. Yaml key: `cppProjections`. - public bool CppProjections { get; set; } = true; - public string? GetVersion(string name) => Packages.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase))?.Version; diff --git a/src/winapp-CLI/WinApp.Cli/Models/WinmdsLockfile.cs b/src/winapp-CLI/WinApp.Cli/Models/WinmdsLockfile.cs index fb4d42ec..0afbc969 100644 --- a/src/winapp-CLI/WinApp.Cli/Models/WinmdsLockfile.cs +++ b/src/winapp-CLI/WinApp.Cli/Models/WinmdsLockfile.cs @@ -5,12 +5,23 @@ namespace WinApp.Cli.Models; -// Lockfile written by `winapp restore`, consumed by the JS bindings step on -// subsequent `winapp restore` runs to keep codegen stable. Optional. +// Lockfile written by `winapp restore`, consumed by the npm wrapper's +// JS-bindings orchestrator on subsequent runs to keep codegen stable. +// Optional — the wrapper can recover by re-running `winapp restore`. +// +// The native CLI emits ONLY this generic NuGet winmd inventory (no +// jsBindings classification: emit/refOnly/skip is computed entirely on +// the npm side from package id heuristics + user overrides). internal sealed class WinmdsLockfile { // Current schema version. Bump on breaking shape changes. - public const int CurrentSchema = 2; + // + // v3 (this version): dropped `category` from WinmdsLockfilePackage — + // classification moved out of native into the npm wrapper. Readers + // built against v2 will ignore the missing field and default-init to + // "emit", which silently mislabels skip/refOnly packages; that's the + // breakage the version bump protects against. + public const int CurrentSchema = 3; // Schema version this file was produced with. public int Schema { get; set; } = CurrentSchema; @@ -38,9 +49,6 @@ internal sealed class WinmdsLockfilePackage // Resolved version. public string Version { get; set; } = string.Empty; - // emit / refOnly / skip. - public string Category { get; set; } = "emit"; - // Absolute paths of every .winmd found for this package. public List Winmds { get; set; } = new(); } diff --git a/src/winapp-CLI/WinApp.Cli/Services/ConfigService.cs b/src/winapp-CLI/WinApp.Cli/Services/ConfigService.cs index e09ff991..494ef503 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/ConfigService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/ConfigService.cs @@ -55,50 +55,6 @@ public void Save(WinappConfig cfg) ConfigPath.Refresh(); } - public void SaveJsBindingsOnly(WinappConfig cfg) - { - GuardConfigPath(); - string yaml; - if (ConfigPath.Exists) - { - string existing; - try - { - existing = File.ReadAllText(ConfigPath.FullName); - } - catch (Exception ex) - { - throw new InvalidOperationException( - $"Could not read existing winapp.yaml at {ConfigPath.FullName} to splice " - + "jsBindings while preserving comments. Close any editor/process that may " - + "be holding the file open, then retry. " - + $"Underlying error: {ex.Message}", ex); - } - - try - { - yaml = new WinappConfigDocument(cfg).SpliceJsBindingsInto(existing); - } - catch (Exception ex) - { - throw new InvalidOperationException( - $"Could not splice jsBindings: block into winapp.yaml at {ConfigPath.FullName} " - + "without losing comments or unknown fields. The file's structure may be " - + "malformed; fix it manually or remove the jsBindings: block and re-run. " - + $"Underlying error: {ex.Message}", ex); - } - } - else - { - yaml = new WinappConfigDocument(cfg).Render(); - } - // Atomic write (temp + rename) so a crash mid-write can't leave - // winapp.yaml truncated. Pairs with reparse-point refusal above to - // make the path safe end-to-end. - PathSafety.AtomicWriteAllText(ConfigPath.FullName, yaml, Utf8NoBom); - ConfigPath.Refresh(); - } - // Refuse to read or rewrite winapp.yaml if the file (or any directory // between it and its config-dir) is a symlink/junction — a malicious // workspace could otherwise redirect the I/O at an arbitrary file on diff --git a/src/winapp-CLI/WinApp.Cli/Services/DynWinrtCodegenService.cs b/src/winapp-CLI/WinApp.Cli/Services/DynWinrtCodegenService.cs deleted file mode 100644 index d91f11c0..00000000 --- a/src/winapp-CLI/WinApp.Cli/Services/DynWinrtCodegenService.cs +++ /dev/null @@ -1,730 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using Microsoft.Extensions.Logging; -using System.Diagnostics; -using System.Runtime.InteropServices; -using System.Text; -using WinApp.Cli.ConsoleTasks; -using WinApp.Cli.Helpers; -using WinApp.Cli.Models; - -namespace WinApp.Cli.Services; - -// Spawns dynwinrt-codegen against discovered .winmd metadata. -internal sealed class DynWinrtCodegenService( - INpmWrapperVersionProvider npmWrapperVersionProvider, - ILogger logger) : IDynWinrtCodegenService -{ - private const string CodegenPackageName = "@microsoft/dynwinrt-codegen"; - - // Dev/test fallback when no npm wrapper layout is available. Production - // reads the version from INpmWrapperVersionProvider (the wrapper's own - // package.json pin). Keep this in sync with src/winapp-npm/package.json. - internal const string CodegenPinnedVersionFallback = "0.1.0-preview.1"; - - // Marker written into the output dir after a successful run; its - // presence authorises the next run to wipe. - public const string ManagedMarkerFileName = ".dynwinrt-managed"; - - public async Task RunAsync( - JsBindingsConfig config, - IReadOnlyList winmds, - FileInfo? windowsSdkWinmd, - DirectoryInfo workspaceDir, - DirectoryInfo winappDir, - TaskContext taskContext, - IReadOnlyList? userAdditionalWinmds = null, - IReadOnlyList? userAdditionalRefs = null, - CancellationToken cancellationToken = default) - { - winappDir.Create(); - - var outputDir = ResolveOutputDir(workspaceDir, config.Output); - outputDir.Parent?.Create(); - - var listedWinmds = CollectListedWinmds(winmds, userAdditionalWinmds, windowsSdkWinmd); - var refWinmds = CollectRefWinmds(userAdditionalRefs, listedWinmds); - - // Locate codegen BEFORE touching the output dir so a missing install - // doesn't first wipe the user's previous bindings. - var versionHint = TryReadCodegenVersionHint(); - var (executable, prefixArgs) = ResolveCodegenInvocation(versionHint); - logger.LogDebug( - "{UISymbol} Resolved dynwinrt-codegen → {Executable} {PrefixArgs}", - UiSymbols.Tools, executable, string.Join(' ', prefixArgs)); - taskContext.AddDebugMessage($"{UiSymbols.Tools} Using codegen → {executable} {string.Join(' ', prefixArgs)}"); - taskContext.AddDebugMessage($"{UiSymbols.Note} Codegen inputs: {listedWinmds.Count} emit + {refWinmds.Count} ref winmd(s)"); - - // Stage-then-swap: failure leaves previous output intact. - await RunWithStagingAsync(outputDir, async stagingDir => - { - // Skip the bulk pass when no emit winmds — extraTypes-only - // cherry-pick (refs + extraTypes, no bulk emit) still runs the - // per-extraType loop below. - if (listedWinmds.Count > 0) - { - var bulkArgs = BuildBulkArgs(prefixArgs, listedWinmds, stagingDir, config, refWinmds); - await SpawnCodegenAsync(executable, bulkArgs, workspaceDir, taskContext, cancellationToken); - } - - // One pass per extraType — cherry-picks a class from the same - // metadata universe as the bulk pass. - foreach (var et in config.ExtraTypes) - { - if (string.IsNullOrWhiteSpace(et.Namespace) || et.Classes.Count == 0) - { - continue; - } - var extraArgs = BuildExtraTypeArgs(prefixArgs, listedWinmds, stagingDir, config, refWinmds, et); - await SpawnCodegenAsync(executable, extraArgs, workspaceDir, taskContext, cancellationToken); - } - }); - - outputDir.Refresh(); - return outputDir; - } - - // Stage → backup-old → swap → drop-backup. Failure at any swap step - // restores the previous output. Internal for tests. - internal static async Task RunWithStagingAsync( - DirectoryInfo outputDir, - Func generate) - { - var stagingDir = new DirectoryInfo( - Path.Combine(outputDir.Parent!.FullName, $"{outputDir.Name}.staging.{Guid.NewGuid():N}")); - DirectoryInfo? backupDir = null; - stagingDir.Create(); - try - { - await generate(stagingDir); - - WriteManagedMarker(stagingDir); - - ValidateOutputDirIsWipeable(outputDir); - - if (outputDir.Exists) - { - backupDir = new DirectoryInfo( - Path.Combine(outputDir.Parent!.FullName, $"{outputDir.Name}.backup.{Guid.NewGuid():N}")); - Directory.Move(outputDir.FullName, backupDir.FullName); - } - - try - { - Directory.Move(stagingDir.FullName, outputDir.FullName); - // Null the local immediately on success so the finally-block - // cleanup can't ever re-target the now-renamed staging dir - // (which IS the user's new output). - stagingDir = null!; - } - catch - { - // Restore the previous output so the user isn't left empty. - if (backupDir is not null && backupDir.Exists) - { - try { Directory.Move(backupDir.FullName, outputDir.FullName); backupDir = null; } - catch (Exception restoreEx) - { - // Restore failed — preserve the backup on disk (it's - // the only surviving copy) and surface the path so - // the user can recover manually. Null the local so - // the finally block doesn't delete it. - var preserved = backupDir!.FullName; - backupDir = null; - throw new IOException( - $"Codegen failed AND the previous output could not be restored. " - + $"Your previous bindings are preserved at: {preserved}. " - + $"Move them back manually if needed. Restore error: {restoreEx.Message}"); - } - } - throw; - } - } - finally - { - if (stagingDir is not null) - { - try { stagingDir.Delete(recursive: true); } - catch { /* orphan staging is harmless */ } - } - if (backupDir is not null) - { - try { backupDir.Delete(recursive: true); } - catch { /* orphan backup is harmless */ } - } - } - } - - // Resolve output dir, refusing escape (typos / reparse points) so the - // pre-codegen wipe stays inside the workspace. - internal static DirectoryInfo ResolveOutputDir(DirectoryInfo workspaceDir, string output) - { - if (string.IsNullOrWhiteSpace(output)) - { - output = "bindings/winrt"; - } - var path = Path.IsPathRooted(output) - ? output - : Path.Combine(workspaceDir.FullName, output); - var resolvedFull = Path.GetFullPath(path); - var workspaceFull = Path.GetFullPath(workspaceDir.FullName) - .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - - // Lexical containment check. - var sep = Path.DirectorySeparatorChar; - var prefix = workspaceFull + sep; - var insideWorkspace = resolvedFull.Length > prefix.Length - && resolvedFull.StartsWith(prefix, StringComparison.OrdinalIgnoreCase); - if (!insideWorkspace) - { - throw new InvalidOperationException( - $"jsBindings.output ('{output}') resolves to '{resolvedFull}' which is outside the workspace " - + $"('{workspaceFull}'). The output directory is wiped before each codegen run, so it must be " - + "a path strictly inside the workspace. Use a relative path like 'bindings/winrt' or an absolute " - + "path that descends from the workspace root."); - } - - // Physical containment: reject reparse points in the chain so the - // recursive delete can't follow a junction outside the workspace. - for (var probe = new DirectoryInfo(resolvedFull); - probe is not null && probe.FullName.Length >= workspaceFull.Length; - probe = probe.Parent) - { - if (probe.Exists && (probe.Attributes & FileAttributes.ReparsePoint) != 0) - { - throw new InvalidOperationException( - $"jsBindings.output ('{output}') resolves through a reparse point at '{probe.FullName}'. " - + "Reparse points (symlinks / junctions) are rejected because they could redirect the output " - + "wipe outside the workspace. Move the output to a regular subdirectory of the workspace."); - } - if (string.Equals(probe.FullName.TrimEnd(sep, Path.AltDirectorySeparatorChar), - workspaceFull, StringComparison.OrdinalIgnoreCase)) - { - break; - } - } - - return new DirectoryInfo(resolvedFull); - } - - // Deduplicated emit-set: packages + user additionalWinmds + optional SDK winmd. - internal static List CollectListedWinmds( - IReadOnlyList winmds, - IReadOnlyList? userAdditional, - FileInfo? windowsSdkWinmd) - { - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - var result = new List(); - void Add(FileInfo? f) - { - if (f is null) - { - return; - } - if (seen.Add(f.FullName)) - { - result.Add(f); - } - } - foreach (var w in winmds) - { - Add(w); - } - if (userAdditional is not null) - { - foreach (var w in userAdditional) - { - Add(w); - } - } - Add(windowsSdkWinmd); - return result; - } - - // Deduplicated ref-set. Entries already in listedWinmds are dropped - // (a file in both wins as emit). - internal static List CollectRefWinmds( - IReadOnlyList? userAdditionalRefs, - IReadOnlyList listedWinmds) - { - var result = new List(); - if (userAdditionalRefs is null || userAdditionalRefs.Count == 0) - { - return result; - } - var listedSet = new HashSet(listedWinmds.Select(f => f.FullName), StringComparer.OrdinalIgnoreCase); - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var f in userAdditionalRefs) - { - if (listedSet.Contains(f.FullName)) - { - continue; - } - if (seen.Add(f.FullName)) - { - result.Add(f); - } - } - return result; - } - - // Wipe outputDir only when safe (empty, or has the managed marker). - // Refuses if the dir or any top-level child is a reparse point. - internal static void WipeOutputDirSafely(DirectoryInfo outputDir) - { - if (!outputDir.Exists) - { - return; - } - - // Validation throws on any unsafe state. - ValidateOutputDirIsWipeable(outputDir); - - var entries = outputDir.EnumerateFileSystemInfos().ToList(); - foreach (var entry in entries) - { - switch (entry) - { - case DirectoryInfo d: - d.Delete(recursive: true); - break; - case FileInfo f: - f.Delete(); - break; - } - } - } - - // Throws if outputDir cannot be safely wiped. Does NOT mutate anything. - // Used by RunWithStagingAsync so we can validate-then-rename-to-backup - // instead of validate-and-delete-then-rename — the latter would lose old - // bindings if the post-wipe rename failed. - internal static void ValidateOutputDirIsWipeable(DirectoryInfo outputDir) - { - if (!outputDir.Exists) - { - return; - } - - if ((outputDir.Attributes & FileAttributes.ReparsePoint) != 0) - { - throw new InvalidOperationException( - $"Refusing to wipe '{outputDir.FullName}': it is a reparse point (symlink or junction). " - + "The wipe could follow the link and delete files outside the workspace. " - + "Move the output to a regular directory and try again."); - } - - var entries = outputDir.EnumerateFileSystemInfos().ToList(); - if (entries.Count == 0) - { - return; - } - - var marker = Path.Combine(outputDir.FullName, ManagedMarkerFileName); - if (!File.Exists(marker)) - { - throw new InvalidOperationException( - $"Refusing to wipe non-managed output directory '{outputDir.FullName}'. " - + $"This directory contains files but does not have a '{ManagedMarkerFileName}' marker, " - + "which indicates it was created or modified outside winapp. " - + "Move or delete its contents manually if you intended to reuse this path for JS bindings."); - } - - foreach (var entry in entries) - { - if ((entry.Attributes & FileAttributes.ReparsePoint) != 0) - { - throw new InvalidOperationException( - $"Refusing to wipe '{outputDir.FullName}': child entry '{entry.Name}' is a reparse point. " - + "Delete it manually before re-running codegen."); - } - } - } - - // Write the managed marker. Only its existence is checked; the body - // (timestamp) is a debugging aid. - internal static void WriteManagedMarker(DirectoryInfo outputDir) - { - outputDir.Create(); - var markerPath = Path.Combine(outputDir.FullName, ManagedMarkerFileName); - var lines = new[] - { - "# Generated by winapp dynwinrt-codegen integration. Do not edit.", - "# Presence of this file authorises winapp to wipe the directory on the next run.", - $"generated_at: {DateTimeOffset.UtcNow:O}", - "", - }; - File.WriteAllText(markerPath, string.Join('\n', lines), new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); - } - - internal static List BuildBulkArgs( - IReadOnlyList prefixArgs, - IReadOnlyList emitWinmds, - DirectoryInfo outputDir, - JsBindingsConfig config, - List refWinmds) - { - var args = new List(prefixArgs) - { - "generate", - "--winmd", string.Join(';', emitWinmds.Select(f => f.FullName)), - "--output", outputDir.FullName, - "--lang", config.Lang, - }; - if (refWinmds.Count > 0) - { - args.Add("--ref"); - args.Add(string.Join(';', refWinmds.Select(f => f.FullName))); - } - if (config.Lang == "py") - { - args.Add("--pyi"); - } - return args; - } - - internal static List BuildExtraTypeArgs( - IReadOnlyList prefixArgs, - IReadOnlyList emitWinmds, - DirectoryInfo outputDir, - JsBindingsConfig config, - List refWinmds, - JsBindingsExtraType extra) - { - var args = new List(prefixArgs) - { - "generate", - }; - // Emit winmds may be empty in the extraTypes-only cherry-pick - // workflow (refs supply metadata for the named types). - if (emitWinmds.Count > 0) - { - args.Add("--winmd"); - args.Add(string.Join(';', emitWinmds.Select(f => f.FullName))); - } - args.AddRange(new[] - { - "--namespace", extra.Namespace, - "--class-name", string.Join(',', extra.Classes), - "--output", outputDir.FullName, - "--lang", config.Lang, - }); - if (refWinmds.Count > 0) - { - args.Add("--ref"); - args.Add(string.Join(';', refWinmds.Select(f => f.FullName))); - } - return args; - } - - private async Task SpawnCodegenAsync( - string executable, - IReadOnlyList args, - DirectoryInfo workspaceDir, - TaskContext taskContext, - CancellationToken cancellationToken) - { - var psi = new ProcessStartInfo - { - FileName = executable, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - WorkingDirectory = workspaceDir.FullName, - }; - foreach (var a in args) - { - psi.ArgumentList.Add(a); - } - - using var p = Process.Start(psi) - ?? throw new InvalidOperationException($"Failed to start codegen process: {executable}"); - - try - { - // Drain stdout+stderr in parallel to avoid pipe-fill deadlock - // (~4KB Windows kernel buffer → serialised reads can hang). - var stdoutTask = p.StandardOutput.ReadToEndAsync(cancellationToken); - var stderrTask = p.StandardError.ReadToEndAsync(cancellationToken); - await Task.WhenAll(stdoutTask, stderrTask); - var stdout = await stdoutTask; - var stderr = await stderrTask; - await p.WaitForExitAsync(cancellationToken); - - if (!string.IsNullOrWhiteSpace(stdout)) - { - taskContext.AddDebugMessage(stdout.TrimEnd()); - } - if (!string.IsNullOrWhiteSpace(stderr)) - { - taskContext.AddDebugMessage(stderr.TrimEnd()); - } - - if (p.ExitCode != 0) - { - logger.LogError("dynwinrt-codegen exited with code {Code}: {Err}", p.ExitCode, stderr); - throw new InvalidOperationException($"dynwinrt-codegen failed (exit {p.ExitCode}). See debug output for details."); - } - } - catch (OperationCanceledException) - { - // Kill the child tree so we don't leak a zombie holding file - // locks on staging. - try - { - if (!p.HasExited) - { - p.Kill(entireProcessTree: true); - try - { - using var killCts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - await p.WaitForExitAsync(killCts.Token); - } - catch (OperationCanceledException) - { - // OS will reap eventually; let cancellation propagate. - } - } - } - catch (Exception killEx) - { - logger.LogDebug(killEx, "Failed to kill cancelled codegen process (pid {Pid})", p.Id); - } - throw; - } - } - - // Resolves the dynwinrt-codegen binary winapp will spawn. Only the - // wrapper-bundled install is honored — workspace-local installs are - // never trusted (they could be substituted by a cloned/malicious repo). - internal static (string Executable, List PrefixArgs) ResolveCodegenInvocation( - string? codegenVersionHint = null) - => ResolveCodegenInvocationCore(TryGetWrapperDir(), codegenVersionHint); - - // Test seam: inject wrapperDir directly. Production reads from - // Environment.ProcessPath (which under test points at testhost.exe). - internal static (string Executable, List PrefixArgs) ResolveCodegenInvocationCore( - DirectoryInfo? wrapperDir, - string? codegenVersionHint = null) - { - var arch = ResolveArchSubdir(); - DirectoryInfo? lastChecked = null; - - if (wrapperDir is not null) - { - var (wrapperHit, wrapperLastChecked) = TryFindCodegenIn(wrapperDir, arch); - if (wrapperHit is not null) - { - return wrapperHit.Value; - } - lastChecked = wrapperLastChecked; - } - - var wrapperLocationHint = wrapperDir is not null - ? $" at '{wrapperDir.FullName}'" - : " (winapp install directory could not be determined; try reinstalling @microsoft/winappcli)"; - - var partialInstallHint = lastChecked is not null - ? $"Found {CodegenPackageName} at '{lastChecked.FullName}' but no executable " - + $"inside (expected 'bin/{arch}/dynwinrt-codegen.exe' or 'cli.js'). " - + "The npm package may be corrupt; reinstall it.\n\n" - : $"Searched {CodegenPackageName} from the wrapper install{wrapperLocationHint} — " - + "no node_modules/@microsoft/dynwinrt-codegen found.\n\n"; - - var versionForHint = codegenVersionHint ?? CodegenPinnedVersionFallback; - - throw new InvalidOperationException( - partialInstallHint - + "To enable JS bindings, install via npm or yarn classic:\n" - + " npm i -D @microsoft/winappcli\n" - + $"(bundles {CodegenPackageName}@{versionForHint} as a transitive dependency.)\n\n" - + "Non-hoisting layouts (pnpm default, yarn-Berry PnP) are not supported: the\n" - + "codegen binary must live next to the winapp launcher so winapp can verify\n" - + "it ships the binary it's spawning. For pnpm, set 'node-linker=hoisted' in\n" - + ".npmrc; for yarn-Berry, set 'nodeLinker: node-modules' in .yarnrc.yml.\n\n" - + "See https://github.com/microsoft/WinAppCli#electron--nodejs for setup details."); - } - - // Walks up from `root` looking for node_modules/@microsoft/dynwinrt-codegen. - private static ( - (string Executable, List PrefixArgs)? Hit, - DirectoryInfo? LastChecked) - TryFindCodegenIn(DirectoryInfo root, string arch) - { - DirectoryInfo? lastChecked = null; - for (var probe = root; probe is not null; probe = probe.Parent) - { - var packageDir = Path.Combine(probe.FullName, "node_modules", "@microsoft", "dynwinrt-codegen"); - if (!Directory.Exists(packageDir)) - { - continue; - } - - // Priority 1: pre-built .exe (no Node startup needed). - var directExe = new FileInfo(Path.Combine(packageDir, "bin", arch, "dynwinrt-codegen.exe")); - if (directExe.Exists) - { - return ((directExe.FullName, new List()), null); - } - - // Priority 2: cli.js via node.exe (defensive fallback). - var localCli = new FileInfo(Path.Combine(packageDir, "cli.js")); - if (localCli.Exists) - { - // Reject .bat/.cmd/.ps1 — those go through cmd.exe parsing - // where user-derived args could be misinterpreted. - var nodePath = ResolveExecutableOnPath("node", nativeOnly: true) - ?? throw new InvalidOperationException( - $"The codegen at '{localCli.FullName}' requires a native Node.js executable " - + "(node.exe) on PATH. Install Node 18+ (winget install OpenJS.NodeJS) " - + $"or install {CodegenPackageName} so the pre-built .exe is available."); - return ((nodePath, new List { localCli.FullName }), null); - } - - // Partial install (no exe + no cli.js); remember and keep walking. - lastChecked = new DirectoryInfo(packageDir); - } - return (null, lastChecked); - } - - // Directory containing winapp.exe. Null when ProcessPath is empty - // (test / `dotnet run`). - private static DirectoryInfo? TryGetWrapperDir() - { - var exePath = Environment.ProcessPath; - if (string.IsNullOrEmpty(exePath)) - { - return null; - } - var dir = Path.GetDirectoryName(exePath); - return string.IsNullOrEmpty(dir) ? null : new DirectoryInfo(dir); - } - - // Read the codegen version from the npm wrapper's package.json; falls - // back to the in-source constant when the provider can't locate it - // (dev / test scenarios outside the npm install layout). - private string? TryReadCodegenVersionHint() - { - try - { - return npmWrapperVersionProvider.DynWinrtCodegenVersion; - } - catch - { - return null; - } - } - - // Resolve `command` via PATH + PATHEXT, skipping CWD-equivalent entries - // to prevent local hijack (e.g. node.exe dropped in the workspace). - // When `nativeOnly: true`, only .exe / .com matches are returned — - // .bat / .cmd / .ps1 dispatch through cmd.exe / pwsh and would re-parse - // any user-derived args, so we reject them in security-sensitive paths. - internal static string? ResolveExecutableOnPath(string command, bool nativeOnly = false) - { - if (string.IsNullOrWhiteSpace(command)) - { - return null; - } - - if (Path.IsPathRooted(command) - || command.Contains(Path.DirectorySeparatorChar) - || command.Contains(Path.AltDirectorySeparatorChar)) - { - return File.Exists(command) ? Path.GetFullPath(command) : null; - } - - var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; - var pathDirs = pathEnv.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries); - var extEnv = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? (Environment.GetEnvironmentVariable("PATHEXT") ?? ".COM;.EXE;.BAT;.CMD") - : string.Empty; - var exts = extEnv.Split(';', StringSplitOptions.RemoveEmptyEntries); - if (nativeOnly) - { - exts = exts.Where(e => - e.Equals(".exe", StringComparison.OrdinalIgnoreCase) - || e.Equals(".com", StringComparison.OrdinalIgnoreCase)).ToArray(); - } - - string? cwdFull = null; - try - { - cwdFull = Path.GetFullPath(Directory.GetCurrentDirectory()); - } - catch - { - // Best-effort; literal "." / "" skips still apply. - } - - foreach (var dir in pathDirs) - { - var trimmed = dir.Trim().Trim('"'); - if (string.IsNullOrEmpty(trimmed) || trimmed == ".") - { - continue; - } - // Reject relative PATH entries entirely — they would be resolved - // against CWD and let a workspace-local `./bin` shadow trusted - // system locations. - if (!Path.IsPathFullyQualified(trimmed)) - { - continue; - } - if (cwdFull is not null) - { - string? resolved = null; - try - { - resolved = Path.GetFullPath(trimmed); - } - catch - { - continue; - } - if (string.Equals(resolved, cwdFull, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - } - - // Bare match (command may already include an extension). In - // nativeOnly mode, only accept when the existing extension is - // .exe/.com — otherwise the caller would unknowingly spawn a - // .bat/.cmd. - var bare = Path.Combine(trimmed, command); - if (File.Exists(bare)) - { - if (nativeOnly) - { - var bareExt = Path.GetExtension(bare); - if (!bareExt.Equals(".exe", StringComparison.OrdinalIgnoreCase) - && !bareExt.Equals(".com", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - } - return Path.GetFullPath(bare); - } - foreach (var ext in exts) - { - var candidate = Path.Combine(trimmed, command + ext); - if (File.Exists(candidate)) - { - return Path.GetFullPath(candidate); - } - } - } - return null; - } - - // Pick the bin// for the process arch. The codegen npm package - // only ships x64 / arm64; other arches map to x64. - private static string ResolveArchSubdir() => RuntimeInformation.ProcessArchitecture switch - { - Architecture.Arm64 => "arm64", - _ => "x64", - }; -} diff --git a/src/winapp-CLI/WinApp.Cli/Services/IConfigService.cs b/src/winapp-CLI/WinApp.Cli/Services/IConfigService.cs index eb977d5a..c4295048 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/IConfigService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/IConfigService.cs @@ -13,7 +13,4 @@ internal interface IConfigService // Full save. Drops comments / unknown fields. void Save(WinappConfig cfg); - - // Splice only the jsBindings: block; preserves rest of yaml. - void SaveJsBindingsOnly(WinappConfig cfg); } diff --git a/src/winapp-CLI/WinApp.Cli/Services/IDynWinrtCodegenService.cs b/src/winapp-CLI/WinApp.Cli/Services/IDynWinrtCodegenService.cs deleted file mode 100644 index 3748a4e4..00000000 --- a/src/winapp-CLI/WinApp.Cli/Services/IDynWinrtCodegenService.cs +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using WinApp.Cli.ConsoleTasks; -using WinApp.Cli.Models; - -namespace WinApp.Cli.Services; - -// Generates JS / TS / Python WinRT bindings via @microsoft/dynwinrt-codegen. -internal interface IDynWinrtCodegenService -{ - // One bulk pass + one pass per extraTypes entry. - Task RunAsync( - JsBindingsConfig config, - IReadOnlyList winmds, - FileInfo? windowsSdkWinmd, - DirectoryInfo workspaceDir, - DirectoryInfo winappDir, - TaskContext taskContext, - IReadOnlyList? userAdditionalWinmds = null, - IReadOnlyList? userAdditionalRefs = null, - CancellationToken cancellationToken = default); -} diff --git a/src/winapp-CLI/WinApp.Cli/Services/IJsBindingsWorkspaceService.cs b/src/winapp-CLI/WinApp.Cli/Services/IJsBindingsWorkspaceService.cs deleted file mode 100644 index 6a2351f3..00000000 --- a/src/winapp-CLI/WinApp.Cli/Services/IJsBindingsWorkspaceService.cs +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using WinApp.Cli.ConsoleTasks; -using WinApp.Cli.Models; - -namespace WinApp.Cli.Services; - -// Single owner of the JS-bindings pipeline; invoked from init/restore Step 5.5 -// when winapp.yaml declares a jsBindings: block. -internal interface IJsBindingsWorkspaceService -{ - // discover → partition → resolve user winmds → codegen → ensure runtime dep. - Task RunAsync( - JsBindingsOrchestrationContext context, - TaskContext taskContext, - CancellationToken cancellationToken = default); - - // Inject @microsoft/dynwinrt into package.json as a production dep, then - // print a package-manager-aware install hint. Called early in init when - // the npm-caller prompt opted into JS bindings so users can `npm install` - // while codegen runs. - void EnsureRuntimeDependencyAndPrintHint(DirectoryInfo workspaceDirectory); -} - -// Inputs to IJsBindingsWorkspaceService.RunAsync. -internal sealed class JsBindingsOrchestrationContext -{ - public required JsBindingsConfig JsBindingsConfig { get; init; } - public required WinappConfig WinappConfig { get; init; } - public required DirectoryInfo WorkspaceDir { get; init; } - public required DirectoryInfo LocalWinappDir { get; init; } - public required DirectoryInfo NugetCacheDir { get; init; } - - // (name → version) incl. transitive deps. Populated by the init / restore - // flow before invoking RunAsync. Null forces the lockfile fast-path or - // live transitive expansion (used by tests and future external callers). - public IReadOnlyDictionary? UsedVersions { get; init; } - - public bool EnsureRuntimeDependency { get; init; } = true; -} - -internal sealed class JsBindingsOrchestrationResult -{ - public required int ExitCode { get; init; } - public required string Message { get; init; } - public DirectoryInfo? OutputDir { get; init; } -} diff --git a/src/winapp-CLI/WinApp.Cli/Services/INpmWrapperVersionProvider.cs b/src/winapp-CLI/WinApp.Cli/Services/INpmWrapperVersionProvider.cs deleted file mode 100644 index 4b3cdfa4..00000000 --- a/src/winapp-CLI/WinApp.Cli/Services/INpmWrapperVersionProvider.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -namespace WinApp.Cli.Services; - -// Pinned version from the @microsoft/winappcli npm wrapper. dynwinrt and -// dynwinrt-codegen ship in lockstep. -public interface INpmWrapperVersionProvider -{ - string DynWinrtVersion { get; } - string DynWinrtCodegenVersion { get; } -} diff --git a/src/winapp-CLI/WinApp.Cli/Services/IPackageManagerDetector.cs b/src/winapp-CLI/WinApp.Cli/Services/IPackageManagerDetector.cs deleted file mode 100644 index f80855f6..00000000 --- a/src/winapp-CLI/WinApp.Cli/Services/IPackageManagerDetector.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using System.IO; - -namespace WinApp.Cli.Services; - -// JS package manager detected from a workspace. -public sealed record DetectedPackageManager(string Name, string InstallCommand); - -// Precedence: Corepack `packageManager` field → lockfile → npm. -public interface IPackageManagerDetector -{ - // Never null — falls back to npm. - DetectedPackageManager Detect(DirectoryInfo workspaceDirectory); -} diff --git a/src/winapp-CLI/WinApp.Cli/Services/IUserPackageJsonService.cs b/src/winapp-CLI/WinApp.Cli/Services/IUserPackageJsonService.cs deleted file mode 100644 index f6b458e6..00000000 --- a/src/winapp-CLI/WinApp.Cli/Services/IUserPackageJsonService.cs +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using System.IO; - -namespace WinApp.Cli.Services; - -public enum RuntimeDependencyOutcome -{ - Added, - AlreadyPresent, - PresentInDevDependencies, - NoPackageJson, -} - -// Edits the user's package.json. Bindings need `dependencies` (not dev), -// or `npm ci --omit=dev` strips them. -public interface IUserPackageJsonService -{ - RuntimeDependencyOutcome EnsureRuntimeDependency( - DirectoryInfo workspaceDirectory, - string packageName, - string version); -} diff --git a/src/winapp-CLI/WinApp.Cli/Services/JsBindingsPresets.cs b/src/winapp-CLI/WinApp.Cli/Services/JsBindingsPresets.cs deleted file mode 100644 index 69010d5a..00000000 --- a/src/winapp-CLI/WinApp.Cli/Services/JsBindingsPresets.cs +++ /dev/null @@ -1,184 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -namespace WinApp.Cli.Services; - -// Codegen role per winmd. -internal enum WinmdPackageCategory -{ - Emit, // --winmd - RefOnly, // --ref - Skip, -} - -// Winmd / package categorization for JS bindings. Owns the static denylists -// (skip / ref-only) and merging them with user `jsBindings:` overrides from -// winapp.yaml. Shared by JsBindingsWorkspaceService and WinmdsLockfileService. -internal static class JsBindingsPresets -{ - // Built-in denylists; user `jsBindings` overrides layer on top. - - // RefOnly: own classes are undriveable but other packages reference them. - private static readonly HashSet DefaultRefOnlyPackages = - new(StringComparer.OrdinalIgnoreCase) - { - "Microsoft.WindowsAppSDK.InteractiveExperiences", - }; - - // Skip: dropped entirely. - private static readonly HashSet DefaultSkippedPackages = - new(StringComparer.OrdinalIgnoreCase) - { - "Microsoft.WindowsAppSDK.WinUI", - }; - - // User overrides. Precedence: Emit → Skip → RefOnly → Emit (default). - public sealed class PackageCategoryOverrides - { - public IReadOnlyCollection? Skip { get; init; } - public IReadOnlyCollection? RefOnly { get; init; } - public IReadOnlyCollection? Emit { get; init; } - - public static readonly PackageCategoryOverrides Empty = new(); - - public static PackageCategoryOverrides From(Models.JsBindingsConfig? config) - { - if (config is null) - { - return Empty; - } - return new PackageCategoryOverrides - { - Skip = config.SkipPackages.Count > 0 - ? new HashSet(config.SkipPackages, StringComparer.OrdinalIgnoreCase) - : null, - RefOnly = config.RefOnlyPackages.Count > 0 - ? new HashSet(config.RefOnlyPackages, StringComparer.OrdinalIgnoreCase) - : null, - Emit = config.EmitPackages.Count > 0 - ? new HashSet(config.EmitPackages, StringComparer.OrdinalIgnoreCase) - : null, - }; - } - } - - // Categorize packageId; defaults to Emit. - public static WinmdPackageCategory ClassifyPackage( - string packageId, - PackageCategoryOverrides? overrides = null) - { - if (string.IsNullOrWhiteSpace(packageId)) - { - return WinmdPackageCategory.Emit; - } - var ov = overrides ?? PackageCategoryOverrides.Empty; - - // Force-emit always wins — lets users opt back in to a denylisted package. - if (ov.Emit is not null && ov.Emit.Contains(packageId)) - { - return WinmdPackageCategory.Emit; - } - if (DefaultSkippedPackages.Contains(packageId) || - (ov.Skip is not null && ov.Skip.Contains(packageId))) - { - return WinmdPackageCategory.Skip; - } - if (DefaultRefOnlyPackages.Contains(packageId) || - (ov.RefOnly is not null && ov.RefOnly.Contains(packageId))) - { - return WinmdPackageCategory.RefOnly; - } - return WinmdPackageCategory.Emit; - } - - // Extract package ID from a NuGet-cache winmd path. With `nugetCacheRoot`, - // returns the child segment of the cache root; without it, scans for a - // literal "packages" segment. Returns null when neither applies. - public static string? ExtractPackageIdFromPath(string winmdPath, string? nugetCacheRoot = null) - { - if (string.IsNullOrWhiteSpace(winmdPath)) - { - return null; - } - - if (!string.IsNullOrWhiteSpace(nugetCacheRoot)) - { - try - { - var full = Path.GetFullPath(winmdPath); - var root = Path.GetFullPath(nugetCacheRoot!) - .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - var rootPrefix = root + Path.DirectorySeparatorChar; - if (full.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase)) - { - var rel = full.Substring(rootPrefix.Length); - var firstSep = rel.IndexOfAny(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }); - return firstSep > 0 ? rel.Substring(0, firstSep) : rel; - } - } - catch - { - // Fall through to legacy heuristic. - } - } - - var segs = winmdPath.Split( - new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, - StringSplitOptions.RemoveEmptyEntries); - for (int i = 0; i < segs.Length - 1; i++) - { - if (segs[i].Equals("packages", StringComparison.OrdinalIgnoreCase)) - { - return segs[i + 1]; - } - } - return null; - } - - // Result of partitioning a discovered winmd list by category. - public readonly record struct WinmdPartition( - IReadOnlyList Emit, - IReadOnlyList RefOnly, - IReadOnlyList Skipped); - - // Partition winmds by category. Entries with no extractable package ID - // default to Emit. When `emitScope` is provided, out-of-scope emit - // packages are demoted to RefOnly so codegen still sees their metadata - // for cross-package type resolution. Skip/RefOnly classifications take - // precedence over scope. - public static WinmdPartition PartitionByPackageCategory( - IReadOnlyList winmds, - PackageCategoryOverrides? overrides = null, - string? nugetCacheRoot = null, - IReadOnlyCollection? emitScope = null) - { - HashSet? scope = emitScope is { Count: > 0 } - ? new HashSet(emitScope, StringComparer.OrdinalIgnoreCase) - : null; - - var emit = new List(); - var refOnly = new List(); - var skipped = new List(); - foreach (var w in winmds) - { - var pkg = ExtractPackageIdFromPath(w.FullName, nugetCacheRoot); - var cat = pkg is null ? WinmdPackageCategory.Emit : ClassifyPackage(pkg, overrides); - - if (scope is not null - && cat == WinmdPackageCategory.Emit - && pkg is not null - && !scope.Contains(pkg)) - { - cat = WinmdPackageCategory.RefOnly; - } - - switch (cat) - { - case WinmdPackageCategory.Skip: skipped.Add(w); break; - case WinmdPackageCategory.RefOnly: refOnly.Add(w); break; - default: emit.Add(w); break; - } - } - return new WinmdPartition(emit, refOnly, skipped); - } -} diff --git a/src/winapp-CLI/WinApp.Cli/Services/JsBindingsWorkspaceService.RuntimeDependency.cs b/src/winapp-CLI/WinApp.Cli/Services/JsBindingsWorkspaceService.RuntimeDependency.cs deleted file mode 100644 index 7ef70989..00000000 --- a/src/winapp-CLI/WinApp.Cli/Services/JsBindingsWorkspaceService.RuntimeDependency.cs +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using Microsoft.Extensions.Logging; -using WinApp.Cli.Helpers; -using WinApp.Cli.Models; - -namespace WinApp.Cli.Services; - -// Adds @microsoft/dynwinrt to the user's package.json after codegen -// and prints an install hint. -internal sealed partial class JsBindingsWorkspaceService -{ - public void EnsureRuntimeDependencyAndPrintHint(DirectoryInfo workspaceDirectory) - { - const string DynWinrtPackageName = "@microsoft/dynwinrt"; - - string version; - try - { - version = npmWrapperVersionProvider.DynWinrtVersion; - } - catch (InvalidOperationException ex) - { - logger.LogWarning( - "{UISymbol} Could not resolve pinned {Package} version: {Reason}", - UiSymbols.Note, DynWinrtPackageName, ex.Message); - return; - } - - RuntimeDependencyOutcome outcome; - try - { - outcome = userPackageJsonService.EnsureRuntimeDependency( - workspaceDirectory, DynWinrtPackageName, version); - } - catch (InvalidOperationException ex) - { - logger.LogWarning( - "{UISymbol} Could not update package.json for {Package}: {Reason}. " + - "Add it manually to your dependencies.", - UiSymbols.Note, DynWinrtPackageName, ex.Message); - return; - } - - switch (outcome) - { - case RuntimeDependencyOutcome.Added: - var pmAdded = packageManagerDetector.Detect(workspaceDirectory); - // Info-level so --quiet suppresses; user runs the printed install cmd next. - logger.LogInformation( - "{UISymbol} Added {Package} @ {Version} to your package.json dependencies. Run `{InstallCmd}` to materialize it.", - UiSymbols.Check, DynWinrtPackageName, version, pmAdded.InstallCommand); - break; - case RuntimeDependencyOutcome.PresentInDevDependencies: - // Warning: production deploys (npm ci --omit=dev) will break. - logger.LogWarning( - "{UISymbol} {Package} is in devDependencies — generated bindings need it as a production dep. Move it manually.", - UiSymbols.Note, DynWinrtPackageName); - break; - case RuntimeDependencyOutcome.NoPackageJson: - logger.LogWarning( - "{UISymbol} No package.json found in workspace. Generated bindings will fail to resolve {Package} at runtime. Run `npm init -y` first.", - UiSymbols.Warning, DynWinrtPackageName); - break; - case RuntimeDependencyOutcome.AlreadyPresent: - default: - logger.LogInformation( - "{UISymbol} {Package} already declared in package.json dependencies — leaving it alone.", - UiSymbols.Check, DynWinrtPackageName); - break; - } - } -} diff --git a/src/winapp-CLI/WinApp.Cli/Services/JsBindingsWorkspaceService.WinmdDiscovery.cs b/src/winapp-CLI/WinApp.Cli/Services/JsBindingsWorkspaceService.WinmdDiscovery.cs deleted file mode 100644 index 2e094eb4..00000000 --- a/src/winapp-CLI/WinApp.Cli/Services/JsBindingsWorkspaceService.WinmdDiscovery.cs +++ /dev/null @@ -1,219 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using Microsoft.Extensions.Logging; -using WinApp.Cli.ConsoleTasks; -using WinApp.Cli.Helpers; -using WinApp.Cli.Models; - -namespace WinApp.Cli.Services; - -// User-supplied winmd path validation (UNC guard, existence, dedupe) -// and live-mode NuGet transitive-dependency expansion. -internal sealed partial class JsBindingsWorkspaceService -{ - private List ResolveAdditionalWinmds( - List entries, - DirectoryInfo workspaceDir, - TaskContext taskContext, - string fieldName) - { - var resolved = new List(); - if (entries is null || entries.Count == 0) - { - return resolved; - } - - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var entry in entries) - { - if (string.IsNullOrWhiteSpace(entry)) - { - continue; - } - var trimmed = entry.Trim(); - - // Reject UNC / network paths before any probe — FileInfo.Exists - // on a UNC triggers SMB negotiation and would leak the user's - // NTLM hash to the remote host. - if (PathSafety.IsNetworkPath(trimmed)) - { - taskContext.AddDebugMessage( - $"{UiSymbols.Warning} {fieldName} entry rejected as network/UNC path (refusing to probe): {entry}"); - logger.LogWarning( - "{UISymbol} jsBindings.{FieldName} entry refused — network/UNC paths are not allowed (would probe attacker-controlled host on FileInfo.Exists). Entry: {Entry}", - UiSymbols.Warning, - fieldName, - entry); - continue; - } - - var fullPath = Path.IsPathFullyQualified(trimmed) - ? Path.GetFullPath(trimmed) - : Path.GetFullPath(Path.Combine(workspaceDir.FullName, trimmed)); - - // Re-check after GetFullPath: a relative path under a UNC - // workspaceDir resolves to a UNC. - if (PathSafety.IsNetworkPath(fullPath)) - { - taskContext.AddDebugMessage( - $"{UiSymbols.Warning} {fieldName} entry resolved to a network/UNC path, rejected: {entry} → {fullPath}"); - logger.LogWarning( - "{UISymbol} jsBindings.{FieldName} entry resolved to UNC path; refusing to probe. Entry: {Entry} → {FullPath}", - UiSymbols.Warning, - fieldName, - entry, - fullPath); - continue; - } - - // Reparse-point guard: walk down from a boundary to fullPath - // and reject if any segment is a symlink/junction. Boundary - // selection: - // * Relative paths and absolute paths under the workspace — - // boundary = workspaceDir. Workspace containment is the - // natural trust scope. - // * Absolute paths outside the workspace — boundary = drive - // root (e.g. `C:\`). The user explicitly opted in to an - // out-of-workspace path (docs/js-bindings.md says absolute - // paths are supported); we still walk every segment for - // reparse points, but we don't force workspace containment. - // PathSafety.HasReparsePointOnPath already handles drive-root - // boundary correctly (see DriveRootBoundary_StillRejectsJunctionDescendant). - var underWorkspace = string.Equals(fullPath, workspaceDir.FullName, StringComparison.OrdinalIgnoreCase) - || fullPath.StartsWith( - workspaceDir.FullName.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, - StringComparison.OrdinalIgnoreCase); - var reparseBoundary = underWorkspace - ? workspaceDir.FullName - : (Path.GetPathRoot(fullPath) ?? workspaceDir.FullName); - if (PathSafety.HasReparsePointOnPath(fullPath, reparseBoundary)) - { - taskContext.AddDebugMessage( - $"{UiSymbols.Warning} {fieldName} entry rejected — file or ancestor is a symlink/junction: {entry} → {fullPath}"); - logger.LogWarning( - "{UISymbol} jsBindings.{FieldName} entry refused — file or one of its ancestors up to {Boundary} is a reparse point. Entry: {Entry} → {FullPath}", - UiSymbols.Warning, - fieldName, - reparseBoundary, - entry, - fullPath); - continue; - } - - if (!seen.Add(fullPath)) - { - continue; - } - - var fi = new FileInfo(fullPath); - if (!fi.Exists) - { - taskContext.AddDebugMessage( - $"{UiSymbols.Note} {fieldName} entry not found, skipping: {entry}"); - logger.LogWarning( - "{UISymbol} jsBindings.{FieldName} entry not found, skipping: {Entry} (resolved to {FullPath})", - UiSymbols.Note, - fieldName, - entry, - fullPath); - continue; - } - - resolved.Add(fi); - } - - return resolved; - } - - // Count extraTypes entries codegen would actually process; entries with - // a blank namespace or no classes are silently skipped. - internal static int CountValidExtraTypes(IReadOnlyList extraTypes) - { - if (extraTypes is null) - { - return 0; - } - var count = 0; - foreach (var et in extraTypes) - { - if (!string.IsNullOrWhiteSpace(et.Namespace) && et.Classes.Count > 0) - { - count++; - } - } - return count; - } - - // (UNC / network-path detector lives on PathSafety so the reparse-point - // guard and winmd discovery share the same definition — see - // PathSafety.IsNetworkPath.) - - internal async Task> ExpandTransitiveDependenciesAsync( - Dictionary usedVersions, - TaskContext taskContext, - CancellationToken cancellationToken) - { - var expanded = new Dictionary(usedVersions, StringComparer.OrdinalIgnoreCase); - var roots = usedVersions.ToList(); - var failures = new List<(string Package, string Version, string Reason)>(); - foreach (var (name, version) in roots) - { - cancellationToken.ThrowIfCancellationRequested(); - try - { - var deps = await nugetService.GetPackageDependenciesAsync(name, version, cancellationToken); - foreach (var (depId, depVersionSpec) in deps) - { - var depVersion = NugetService.ParseMinimumVersion(depVersionSpec); - if (string.IsNullOrEmpty(depVersion)) - { - continue; - } - if (!expanded.ContainsKey(depId)) - { - expanded[depId] = depVersion; - } - } - } - catch (OperationCanceledException) - { - // User cancellation must propagate — never swallow. - throw; - } - catch (Exception ex) - { - // Record but keep going; surface a single warning at the - // end so users know bindings may be incomplete. - failures.Add((name, version, ex.Message)); - taskContext.AddDebugMessage( - $"{UiSymbols.Note} Could not expand transitive deps for {name} {version}: {ex.Message}"); - logger.LogDebug(ex, - "Transitive dependency expansion failed for {PackageName} {Version}", name, version); - } - } - - if (failures.Count > 0) - { - var summary = string.Join(", ", - failures.Take(5).Select(f => $"{f.Package} {f.Version}")); - var ellipsis = failures.Count > 5 ? $" (+ {failures.Count - 5} more)" : string.Empty; - logger.LogWarning( - "{UISymbol} Could not resolve transitive NuGet dependencies for {Count} package(s): {Packages}{Ellipsis}. " - + "Generated bindings may be incomplete (missing referenced types). " - + "Run `winapp restore` to materialize the full dependency graph and try again.", - UiSymbols.Warning, - failures.Count, - summary, - ellipsis); - taskContext.AddDebugMessage( - $"{UiSymbols.Warning} Transitive expansion failures ({failures.Count}):"); - foreach (var f in failures) - { - taskContext.AddDebugMessage($" - {f.Package} {f.Version}: {f.Reason}"); - } - } - - return expanded; - } -} diff --git a/src/winapp-CLI/WinApp.Cli/Services/JsBindingsWorkspaceService.cs b/src/winapp-CLI/WinApp.Cli/Services/JsBindingsWorkspaceService.cs deleted file mode 100644 index b3e22c25..00000000 --- a/src/winapp-CLI/WinApp.Cli/Services/JsBindingsWorkspaceService.cs +++ /dev/null @@ -1,377 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using Microsoft.Extensions.Logging; -using System.Text.Json; -using WinApp.Cli.ConsoleTasks; -using WinApp.Cli.Helpers; -using WinApp.Cli.Models; - -namespace WinApp.Cli.Services; - -// Default IJsBindingsWorkspaceService — orchestration entrypoint. -// Split into partials: .WinmdDiscovery.cs and .RuntimeDependency.cs. -internal sealed partial class JsBindingsWorkspaceService( - IPackageLayoutService packageLayoutService, - IWinmdsLockfileService winmdsLockfileService, - IDynWinrtCodegenService dynWinrtCodegenService, - INugetService nugetService, - IUserPackageJsonService userPackageJsonService, - INpmWrapperVersionProvider npmWrapperVersionProvider, - IPackageManagerDetector packageManagerDetector, - ILogger logger) : IJsBindingsWorkspaceService -{ - public async Task RunAsync( - JsBindingsOrchestrationContext context, - TaskContext taskContext, - CancellationToken cancellationToken = default) - { - try - { - // User winmds first — they can satisfy the no-package-winmds case. - var userWinmds = ResolveAdditionalWinmds( - context.JsBindingsConfig.AdditionalWinmds, - context.WorkspaceDir, - taskContext, - fieldName: "additionalWinmds"); - var userRefs = ResolveAdditionalWinmds( - context.JsBindingsConfig.AdditionalRefs, - context.WorkspaceDir, - taskContext, - fieldName: "additionalRefs"); - - // Step 2: discover package winmds. - var (bindingWinmds, packageRefs, skippedCount, usedVersions) = - await DiscoverWinmdsAsync(context, taskContext, cancellationToken); - - // Empty package discovery is OK if the user supplied their own. - if (bindingWinmds is null || packageRefs is null) - { - if (userWinmds.Count == 0) - { - return new JsBindingsOrchestrationResult - { - ExitCode = 1, - Message = "No .winmd files found for JS binding generation. " - + "Likely causes:\n" - + " • Packages aren't restored yet → run [bold]npx winapp restore[/]\n" - + " • Stale [bold].winapp/winmds.lock.json[/] → re-run restore to regenerate\n" - + " • [bold]jsBindings.packages[/] in winapp.yaml lists package IDs that aren't installed", - }; - } - bindingWinmds = new List(); - packageRefs = new List(); - taskContext.AddDebugMessage( - $"{UiSymbols.Note} No package .winmd files in scope; emitting bindings from additionalWinmds: only ({userWinmds.Count} file(s))."); - } - - // Reject empty emit unless this is a valid extraTypes-only flow - // (refs + at least one valid extraType). Otherwise codegen would - // see --winmd "". - var validExtraTypeCount = CountValidExtraTypes(context.JsBindingsConfig.ExtraTypes); - var hasExtraTypesOnlyFlow = - validExtraTypeCount > 0 - && (userRefs.Count > 0 || packageRefs.Count > 0); - if (bindingWinmds.Count + userWinmds.Count == 0 && !hasExtraTypesOnlyFlow) - { - var extraTypesHint = context.JsBindingsConfig.ExtraTypes.Count > 0 - && validExtraTypeCount == 0 - ? "\n • [bold]extraTypes[/] entries are all malformed (missing namespace or classes) — codegen would skip them all" - : "\n • For an extraTypes-only cherry-pick, ensure [bold]additionalRefs[/] (or a refOnly package) is also set"; - return new JsBindingsOrchestrationResult - { - ExitCode = 1, - Message = "No .winmd files left to emit bindings for after applying jsBindings overrides. " - + "Likely causes:\n" - + " • All packages in [bold]jsBindings.packages[/] are categorized as skip/refOnly (check [bold]skipPackages[/] / [bold]refOnlyPackages[/])\n" - + " • [bold]jsBindings.packages[/] doesn't match any restored package — verify package IDs against winapp.yaml\n" - + " • Add at least one emit-set package, or use [bold]additionalWinmds[/] to supply winmds directly" - + extraTypesHint, - }; - } - - if (packageRefs.Count > 0 || skippedCount > 0) - { - taskContext.AddDebugMessage( - $"{UiSymbols.Note} winmd partition: emit={bindingWinmds.Count}, ref-only={packageRefs.Count}, skipped={skippedCount}"); - } - - var combinedRefs = MergeRefWinmds(packageRefs, userRefs); - - // Step 3: codegen (staging-then-swap internally). - taskContext.UpdateSubStatus("Generating bindings"); - var outputDir = await dynWinrtCodegenService.RunAsync( - context.JsBindingsConfig, - bindingWinmds, - windowsSdkWinmd: null, - workspaceDir: context.WorkspaceDir, - winappDir: context.LocalWinappDir, - taskContext: taskContext, - userAdditionalWinmds: userWinmds, - userAdditionalRefs: combinedRefs, - cancellationToken: cancellationToken); - - // (No lockfile write here: the restore step already writes the - // full lockfile before this overlay runs — rewriting it with the - // scoped emit subset would lose packages outside jsBindings.packages.) - - // Ensure @microsoft/dynwinrt is a production dep so generated - // bindings resolve at runtime. - if (context.EnsureRuntimeDependency) - { - EnsureRuntimeDependencyAndPrintHint(context.WorkspaceDir); - } - - return new JsBindingsOrchestrationResult - { - ExitCode = 0, - Message = $"JS bindings generated → [underline]{outputDir.FullName}[/]", - OutputDir = outputDir, - }; - } - catch (OperationCanceledException) - { - return new JsBindingsOrchestrationResult { ExitCode = 1, Message = "JS binding generation cancelled." }; - } - catch (InvalidOperationException ex) - { - // Surface actionable codegen/config errors verbatim (they're - // safe-to-display by contract). - taskContext.AddDebugMessage($"{UiSymbols.Note} JS binding generation failed: {ex.Message}"); - logger.LogDebug(ex, "JS binding generation failed"); - return new JsBindingsOrchestrationResult { ExitCode = 1, Message = ex.Message }; - } - catch (Exception ex) - { - taskContext.AddDebugMessage($"{UiSymbols.Note} JS binding generation failed: {ex.Message}"); - logger.LogDebug(ex, "JS binding generation failed"); - return new JsBindingsOrchestrationResult - { - ExitCode = 1, - Message = $"JS binding generation failed: {ex.Message}", - }; - } - } - - // Lockfile fast-path or live discovery. Returns nulls when no winmds found. - private async Task<( - List? BindingWinmds, - List? PackageRefs, - int SkippedCount, - IReadOnlyDictionary? UsedVersions)> - DiscoverWinmdsAsync( - JsBindingsOrchestrationContext context, - TaskContext taskContext, - CancellationToken cancellationToken) - { - // Init/restore already has usedVersions → skip the lockfile fast-path. - if (context.UsedVersions is not null) - { - return await LiveDiscoveryAsync(context.UsedVersions, context, taskContext, cancellationToken); - } - - var lockfile = await winmdsLockfileService.TryReadAsync(context.LocalWinappDir, cancellationToken); - var currentYamlHash = YamlPackagesHasher.Compute(context.WinappConfig.Packages); - if (lockfile is not null - && !string.IsNullOrEmpty(lockfile.YamlPackagesHash) - && !string.Equals(lockfile.YamlPackagesHash, currentYamlHash, StringComparison.Ordinal)) - { - taskContext.AddDebugMessage( - $"{UiSymbols.Note} Winmds lockfile is stale (yaml packages: changed since restore); falling back to live discovery."); - lockfile = null; - } - - if (lockfile is not null) - { - taskContext.AddDebugMessage( - $"{UiSymbols.Note} Using winmds lockfile (generated {lockfile.GeneratedAt}, {lockfile.Packages.Count} packages)"); - var (emit, refOnly, skipped) = PartitionFromLockfile( - lockfile, - context.JsBindingsConfig.Packages, - JsBindingsPresets.PackageCategoryOverrides.From(context.JsBindingsConfig)); - - // Recorded paths still exist? - var missing = emit.Concat(refOnly).Count(f => !f.Exists); - if (missing > 0) - { - taskContext.AddDebugMessage( - $"{UiSymbols.Note} Winmds lockfile references {missing} missing file(s) (NuGet cache cleared?); falling back to live discovery."); - lockfile = null; - } - else if (emit.Count == 0 && refOnly.Count == 0) - { - // Empty after partition — distinct from "no winmds at all". - taskContext.AddDebugMessage( - $"{UiSymbols.Note} Lockfile has no winmds matching the configured packages. Adjust jsBindings.packages or re-run winapp restore."); - return (emit, refOnly, skipped, null); - } - else - { - return (emit, refOnly, skipped, null); - } - } - - // Slow path: cache walk + transitive expansion. - taskContext.AddDebugMessage( - $"{UiSymbols.Note} No usable winmds lockfile; falling back to live discovery (re-run [bold]winapp restore[/] to enable the fast path)."); - - var explicitVersions = context.WinappConfig.Packages.ToDictionary( - p => p.Name, p => p.Version, StringComparer.OrdinalIgnoreCase); - if (explicitVersions.Count == 0) - { - return (null, null, 0, null); - } - - taskContext.UpdateSubStatus("Resolving package graph"); - var derivedUsedVersions = await ExpandTransitiveDependenciesAsync(explicitVersions, taskContext, cancellationToken); - if (derivedUsedVersions.Count > explicitVersions.Count) - { - taskContext.AddDebugMessage( - $"{UiSymbols.Note} Expanded {explicitVersions.Count} pinned package(s) → {derivedUsedVersions.Count} total (with transitive deps)"); - } - return await LiveDiscoveryAsync(derivedUsedVersions, context, taskContext, cancellationToken); - } - - private async Task<( - List? BindingWinmds, - List? PackageRefs, - int SkippedCount, - IReadOnlyDictionary? UsedVersions)> - LiveDiscoveryAsync( - IReadOnlyDictionary usedVersions, - JsBindingsOrchestrationContext context, - TaskContext taskContext, - CancellationToken cancellationToken) - { - await Task.Yield(); - cancellationToken.ThrowIfCancellationRequested(); - taskContext.UpdateSubStatus("Discovering .winmd metadata"); - - // Discover ALL package winmds; the scope narrows EMIT output, not - // codegen's metadata visibility — non-scoped dependencies still flow - // through as RefOnly for cross-package type resolution. - var allPackageWinmds = packageLayoutService.FindWinmds( - context.NugetCacheDir, - new Dictionary(usedVersions, StringComparer.OrdinalIgnoreCase)).ToList(); - if (allPackageWinmds.Count == 0) - { - return (null, null, 0, usedVersions); - } - - var partition = JsBindingsPresets.PartitionByPackageCategory( - allPackageWinmds, - JsBindingsPresets.PackageCategoryOverrides.From(context.JsBindingsConfig), - context.NugetCacheDir.FullName, - emitScope: context.JsBindingsConfig.Packages); - return (partition.Emit.ToList(), partition.RefOnly.ToList(), partition.Skipped.Count, usedVersions); - } - - // ───────────────────────────────────────────────────────────────────── - // Helpers (originally lived in WorkspaceSetupService). - // ───────────────────────────────────────────────────────────────────── - - internal static Dictionary ScopeUsedVersionsToBindingPackages( - Dictionary usedVersions, - IReadOnlyList? bindingPackages) - { - if (bindingPackages is null || bindingPackages.Count == 0) - { - return usedVersions; - } - var allow = new HashSet(bindingPackages, StringComparer.OrdinalIgnoreCase); - var filtered = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var (pkg, ver) in usedVersions) - { - if (allow.Contains(pkg)) - { - filtered[pkg] = ver; - } - } - return filtered; - } - - internal static (List Emit, List RefOnly, int SkippedCount) PartitionFromLockfile( - WinmdsLockfile lockfile, - IReadOnlyList? scopePackages, - JsBindingsPresets.PackageCategoryOverrides? overrides = null) - { - HashSet? scope = null; - if (scopePackages is { Count: > 0 }) - { - scope = new HashSet(scopePackages, StringComparer.OrdinalIgnoreCase); - } - - var emit = new List(); - var refOnly = new List(); - var skippedCount = 0; - foreach (var pkg in lockfile.Packages) - { - // Scope is applied AFTER classification — unscoped emit packages - // are demoted to RefOnly so codegen still sees them for type - // resolution. Skip/RefOnly classifications are scope-independent. - var cat = JsBindingsPresets.ClassifyPackage(pkg.Name, overrides); - if (scope is not null - && cat == WinmdPackageCategory.Emit - && !scope.Contains(pkg.Name)) - { - cat = WinmdPackageCategory.RefOnly; - } - - switch (cat) - { - case WinmdPackageCategory.Skip: - skippedCount += pkg.Winmds.Count > 0 ? 1 : 0; - break; - case WinmdPackageCategory.RefOnly: - foreach (var path in pkg.Winmds) - { - // Drop UNC paths so a tampered lockfile can't - // trigger credential-leaking SMB probes downstream. - if (PathSafety.IsNetworkPath(path)) - { - continue; - } - refOnly.Add(new FileInfo(path)); - } - break; - default: - foreach (var path in pkg.Winmds) - { - if (PathSafety.IsNetworkPath(path)) - { - continue; - } - emit.Add(new FileInfo(path)); - } - break; - } - } - return (emit, refOnly, skippedCount); - } - - internal static List MergeRefWinmds( - IReadOnlyList first, - IReadOnlyList? second) - { - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - var result = new List(); - foreach (var f in first) - { - if (seen.Add(f.FullName)) - { - result.Add(f); - } - } - if (second is not null) - { - foreach (var f in second) - { - if (seen.Add(f.FullName)) - { - result.Add(f); - } - } - } - return result; - } -} diff --git a/src/winapp-CLI/WinApp.Cli/Services/NpmWrapperVersionProvider.cs b/src/winapp-CLI/WinApp.Cli/Services/NpmWrapperVersionProvider.cs deleted file mode 100644 index b2b97ccf..00000000 --- a/src/winapp-CLI/WinApp.Cli/Services/NpmWrapperVersionProvider.cs +++ /dev/null @@ -1,122 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using System; -using System.IO; -using System.Text.Json; - -namespace WinApp.Cli.Services; - -// Walks up from winapp.exe to the wrapper's package.json (name = -// "@microsoft/winappcli") and reads the dynwinrt-codegen dep pin. Only -// dynwinrt-codegen is in dependencies; dynwinrt shares the same pin. -internal sealed class NpmWrapperVersionProvider : INpmWrapperVersionProvider -{ - private const string WrapperPackageName = "@microsoft/winappcli"; - private const string DynWinrtCodegenPackageName = "@microsoft/dynwinrt-codegen"; - - private readonly Lazy _version; - - public NpmWrapperVersionProvider() - { - _version = new Lazy(Locate); - } - - public string DynWinrtVersion => _version.Value; - - public string DynWinrtCodegenVersion => _version.Value; - - private static string Locate() - { - var exePath = Environment.ProcessPath; - if (string.IsNullOrEmpty(exePath)) - { - throw new InvalidOperationException( - "Environment.ProcessPath is empty. Cannot locate the @microsoft/winappcli npm wrapper. " + - "If you reached this from a test or `dotnet run`, register a stub " + - "INpmWrapperVersionProvider in DI."); - } - - return LocateFrom(Path.GetDirectoryName(exePath)!); - } - - // Internal seam so tests can drive a synthetic layout without - // shelling through Environment.ProcessPath. - internal static string LocateFrom(string startDirectory) - { - var dir = new DirectoryInfo(startDirectory); - while (dir != null) - { - var candidate = Path.Combine(dir.FullName, "package.json"); - if (File.Exists(candidate)) - { - if (TryReadVersion(candidate, out var version)) - { - return version; - } - } - - dir = dir.Parent; - } - - throw new InvalidOperationException( - $"Could not locate the {WrapperPackageName} package.json near {startDirectory}. " + - $"This typically means winapp.exe is running outside its npm install layout " + - $"(e.g. `dotnet run` during local development). Register a stub " + - $"INpmWrapperVersionProvider in DI for that scenario."); - } - - private static bool TryReadVersion(string packageJsonPath, out string version) - { - version = string.Empty; - try - { - using var stream = File.OpenRead(packageJsonPath); - using var doc = JsonDocument.Parse(stream); - var root = doc.RootElement; - - if (!root.TryGetProperty("name", out var nameProp) || - nameProp.ValueKind != JsonValueKind.String || - !string.Equals(nameProp.GetString(), WrapperPackageName, StringComparison.Ordinal)) - { - return false; - } - - if (!root.TryGetProperty("dependencies", out var deps) || - deps.ValueKind != JsonValueKind.Object) - { - throw new InvalidOperationException( - $"{packageJsonPath} is the {WrapperPackageName} package.json but has no 'dependencies' object."); - } - - version = ReadDep(deps, DynWinrtCodegenPackageName, packageJsonPath); - return true; - } - catch (JsonException ex) - { - throw new InvalidOperationException( - $"Failed to parse {packageJsonPath}: {ex.Message}", ex); - } - } - - private static string ReadDep(JsonElement deps, string packageName, string packageJsonPath) - { - if (!deps.TryGetProperty(packageName, out var prop) || - prop.ValueKind != JsonValueKind.String) - { - throw new InvalidOperationException( - $"{packageJsonPath} is missing '{packageName}' in its 'dependencies'. " + - $"This indicates a build issue with the @microsoft/winappcli npm package."); - } - - var version = prop.GetString(); - if (string.IsNullOrWhiteSpace(version)) - { - throw new InvalidOperationException( - $"{packageJsonPath} declares '{packageName}' with an empty version."); - } - - return version; - } -} - diff --git a/src/winapp-CLI/WinApp.Cli/Services/PackageManagerDetector.cs b/src/winapp-CLI/WinApp.Cli/Services/PackageManagerDetector.cs deleted file mode 100644 index df16f81a..00000000 --- a/src/winapp-CLI/WinApp.Cli/Services/PackageManagerDetector.cs +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using System; -using System.IO; -using System.Text.Json; - -namespace WinApp.Cli.Services; - -internal sealed class PackageManagerDetector : IPackageManagerDetector -{ - public DetectedPackageManager Detect(DirectoryInfo workspaceDirectory) - { - ArgumentNullException.ThrowIfNull(workspaceDirectory); - - // Priority 1: Corepack `packageManager` field (e.g. "pnpm@9.2.0"). - var packageJson = Path.Combine(workspaceDirectory.FullName, "package.json"); - if (File.Exists(packageJson)) - { - var fromCorepack = TryReadCorepackField(packageJson); - if (fromCorepack != null) - { - return fromCorepack; - } - } - - // Priority 2: lockfile sniffing. pnpm/yarn/bun first because - // package-lock.json is sometimes auto-created by tools in non-npm - // workspaces. - if (File.Exists(Path.Combine(workspaceDirectory.FullName, "pnpm-lock.yaml"))) - { - return new DetectedPackageManager("pnpm", "pnpm install"); - } - - if (File.Exists(Path.Combine(workspaceDirectory.FullName, "yarn.lock"))) - { - return new DetectedPackageManager("yarn", "yarn install"); - } - - if (File.Exists(Path.Combine(workspaceDirectory.FullName, "bun.lockb")) || - File.Exists(Path.Combine(workspaceDirectory.FullName, "bun.lock"))) - { - return new DetectedPackageManager("bun", "bun install"); - } - - if (File.Exists(Path.Combine(workspaceDirectory.FullName, "package-lock.json")) || - File.Exists(Path.Combine(workspaceDirectory.FullName, "npm-shrinkwrap.json"))) - { - return new DetectedPackageManager("npm", "npm install"); - } - - // Fallback. - return new DetectedPackageManager("npm", "npm install"); - } - - private static DetectedPackageManager? TryReadCorepackField(string packageJsonPath) - { - try - { - using var stream = File.OpenRead(packageJsonPath); - using var doc = JsonDocument.Parse(stream); - if (!doc.RootElement.TryGetProperty("packageManager", out var prop) || - prop.ValueKind != JsonValueKind.String) - { - return null; - } - - var raw = prop.GetString(); - if (string.IsNullOrWhiteSpace(raw)) - { - return null; - } - - // Format: "@" with optional "+sha" suffix. - var atIndex = raw.IndexOf('@'); - var name = atIndex >= 0 ? raw[..atIndex] : raw; - return name.Trim().ToLowerInvariant() switch - { - "npm" => new DetectedPackageManager("npm", "npm install"), - "yarn" => new DetectedPackageManager("yarn", "yarn install"), - "pnpm" => new DetectedPackageManager("pnpm", "pnpm install"), - "bun" => new DetectedPackageManager("bun", "bun install"), - _ => null, // Unknown PM declaration; fall through to lockfile sniffing. - }; - } - catch (JsonException) - { - return null; - } - catch (IOException) - { - return null; - } - } -} diff --git a/src/winapp-CLI/WinApp.Cli/Services/UserPackageJsonService.cs b/src/winapp-CLI/WinApp.Cli/Services/UserPackageJsonService.cs deleted file mode 100644 index 23fa757c..00000000 --- a/src/winapp-CLI/WinApp.Cli/Services/UserPackageJsonService.cs +++ /dev/null @@ -1,164 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; -using WinApp.Cli.Helpers; - -namespace WinApp.Cli.Services; - -internal sealed class UserPackageJsonService : IUserPackageJsonService -{ - private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); - - public RuntimeDependencyOutcome EnsureRuntimeDependency( - DirectoryInfo workspaceDirectory, - string packageName, - string version) - { - ArgumentNullException.ThrowIfNull(workspaceDirectory); - ArgumentException.ThrowIfNullOrWhiteSpace(packageName); - ArgumentException.ThrowIfNullOrWhiteSpace(version); - - var packageJsonPath = Path.Combine(workspaceDirectory.FullName, "package.json"); - - // Refuse symlinks/junctions and reparse-point ancestors BEFORE any - // probe. `File.Exists` internally calls FindFirstFile which on a - // reparse-point ancestor would silently follow the link (and on a - // UNC ancestor would trigger SMB negotiation / NTLM leak) before - // we got a chance to refuse. Run the non-probing string + attribute - // check first. - if (PathSafety.HasReparsePointOnPath(packageJsonPath, workspaceDirectory.FullName)) - { - throw new InvalidOperationException( - $"Refusing to rewrite '{packageJsonPath}': the file or one of its " - + "ancestors is a symbolic link / reparse point. Resolve the link " - + "and re-run, or add the runtime dependency manually."); - } - - if (!File.Exists(packageJsonPath)) - { - return RuntimeDependencyOutcome.NoPackageJson; - } - - // JsonNode preserves unrelated keys exactly; JsonSerializer would - // re-shape the whole file. - JsonNode? root; - try - { - using var stream = File.OpenRead(packageJsonPath); - root = JsonNode.Parse(stream); - } - catch (JsonException ex) - { - throw new InvalidOperationException( - $"Failed to parse {packageJsonPath}: {ex.Message}", ex); - } - catch (IOException ex) - { - throw new InvalidOperationException( - $"Failed to read {packageJsonPath}: {ex.Message}", ex); - } - catch (UnauthorizedAccessException ex) - { - throw new InvalidOperationException( - $"No permission to read {packageJsonPath}: {ex.Message}", ex); - } - - if (root is not JsonObject obj) - { - throw new InvalidOperationException( - $"{packageJsonPath} root is not a JSON object."); - } - - if (obj["dependencies"] is JsonObject deps && deps[packageName] != null) - { - return RuntimeDependencyOutcome.AlreadyPresent; - } - - // Don't auto-promote dev→dep; the user pinned it under dev for a reason. - if (obj["devDependencies"] is JsonObject devDeps && devDeps[packageName] != null) - { - return RuntimeDependencyOutcome.PresentInDevDependencies; - } - - if (obj["dependencies"] is not JsonObject deps2) - { - deps2 = new JsonObject(); - // Insert "dependencies" right after "version" (conventional layout). - obj = ReinsertWithDependencies(obj, deps2); - root = obj; - } - deps2[packageName] = JsonValue.Create(version); - - // 2-space indent matches npm/yarn/pnpm; relaxed escaping keeps '/' readable. - var options = new JsonSerializerOptions - { - WriteIndented = true, - Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, - }; - var serialized = root.ToJsonString(options); - - // Preserve trailing newline if the original had one. - try - { - var original = File.ReadAllText(packageJsonPath); - if (original.EndsWith('\n') && !serialized.EndsWith('\n')) - { - serialized += '\n'; - } - - // Atomic write: stage to sibling temp + rename so a crash mid-write - // cannot leave the user with an invalid / empty package.json. - PathSafety.AtomicWriteAllText(packageJsonPath, serialized, Utf8NoBom); - } - catch (IOException ex) - { - throw new InvalidOperationException( - $"Failed to write {packageJsonPath}: {ex.Message}", ex); - } - catch (UnauthorizedAccessException ex) - { - throw new InvalidOperationException( - $"No permission to write {packageJsonPath}: {ex.Message}", ex); - } - return RuntimeDependencyOutcome.Added; - } - - // Rebuild `original` with `newDependencies` slotted right after "version" - // (or appended). JsonNode children can only have one parent, so we detach - // and re-parent. - private static JsonObject ReinsertWithDependencies(JsonObject original, JsonObject newDependencies) - { - var rebuilt = new JsonObject(); - bool inserted = false; - - var entries = original.ToList(); - foreach (var kvp in entries) - { - original.Remove(kvp.Key); - } - - foreach (var (key, value) in entries) - { - rebuilt[key] = value; - if (!inserted && string.Equals(key, "version", StringComparison.Ordinal)) - { - rebuilt["dependencies"] = newDependencies; - inserted = true; - } - } - - if (!inserted) - { - rebuilt["dependencies"] = newDependencies; - } - - return rebuilt; - } -} diff --git a/src/winapp-CLI/WinApp.Cli/Services/WinappConfigDocument.cs b/src/winapp-CLI/WinApp.Cli/Services/WinappConfigDocument.cs index 6d1f7eaf..19b5dcea 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/WinappConfigDocument.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/WinappConfigDocument.cs @@ -7,13 +7,18 @@ namespace WinApp.Cli.Services; /// -/// Hand-rolled reader / writer / splicer for the small winapp.yaml grammar -/// (packages + jsBindings only). Pure data class — no DI, no file I/O. +/// Hand-rolled reader / writer for the small winapp.yaml grammar that the +/// native CLI owns (packages:). Pure data class — no DI, no file I/O. /// /// Mirrors : is /// the thin file-I/O wrapper; this class owns the YAML grammar. Splitting /// keeps grammar changes (which evolve with the schema) from leaking into /// the service surface tests assert against. +/// +/// Unknown top-level keys are silently ignored on read and dropped on a full +/// . Callers that need to round-trip an unknown block +/// (e.g. tooling that layers extra metadata into winapp.yaml) must avoid +/// Save() and read/rewrite the raw yaml text themselves. /// internal sealed class WinappConfigDocument { @@ -34,112 +39,10 @@ public static WinappConfigDocument Parse(string yaml) } /// - /// Full re-serialization. Drops comments and unknown fields — use - /// when you need to preserve the - /// rest of the file. + /// Full re-serialization. Drops comments and unknown fields. /// public string Render() => Stringify(Config); - /// - /// Replace (or insert) just the jsBindings: block inside the existing - /// yaml text, preserving comments, unknown fields, blank lines, and - /// original line endings. Returns the rewritten yaml text. - /// - public string SpliceJsBindingsInto(string existingYaml) - => SpliceJsBindingsBlock(existingYaml ?? string.Empty, Config.JsBindings); - - // ------------------------------------------------------------------------- - // Splice - // ------------------------------------------------------------------------- - - // Splice a new jsBindings: block into existingYaml. Block bounds: a - // zero-indent "jsBindings:" line → next zero-indent non-blank line (or - // EOF). null `newJsBindings` removes the block. - internal static string SpliceJsBindingsBlock(string existingYaml, JsBindingsConfig? newJsBindings) - { - string? replacement = null; - if (newJsBindings is not null) - { - var sb = new StringBuilder(); - AppendJsBindingsBlock(sb, newJsBindings); - replacement = sb.ToString(); - } - - // Line-by-line scan; preserve original newline style. - var lines = existingYaml.Split('\n'); - int blockStart = -1; - int blockEnd = -1; // exclusive end (next line index) - for (int i = 0; i < lines.Length; i++) - { - var trimmed = lines[i].TrimEnd('\r'); - if (IsTopLevelKey(trimmed, "jsBindings:")) - { - if (lines[i].Length > 0 && char.IsWhiteSpace(lines[i][0])) - { - continue; // nested, not a top-level key - } - blockStart = i; - // Find block end: next zero-indent non-blank line, or EOF. - // Zero-indent comments belong to the *next* top-level section - // (or to the file tail), not to jsBindings — preserve them. - blockEnd = lines.Length; - for (int j = i + 1; j < lines.Length; j++) - { - var t = lines[j].TrimEnd('\r'); - if (t.Length == 0) - { - continue; // blank lines belong to no block - } - if (!char.IsWhiteSpace(lines[j][0])) - { - // Any zero-indent line (key OR comment) ends the block. - blockEnd = j; - break; - } - } - break; - } - } - - if (blockStart >= 0) - { - var before = string.Join('\n', lines.Take(blockStart)); - var after = string.Join('\n', lines.Skip(blockEnd)); - var middle = replacement ?? string.Empty; - // Careful newline stitching — avoid double blanks / dropped trailing newline. - var result = new StringBuilder(); - if (before.Length > 0) - { - result.Append(before); - if (!before.EndsWith('\n')) - { - result.Append('\n'); - } - } - if (middle.Length > 0) - { - result.Append(middle); - if (!middle.EndsWith('\n')) - { - result.Append('\n'); - } - } - if (after.Length > 0) - { - result.Append(after); - } - return result.ToString(); - } - - // No existing block — append the new one (if any) at the end. - if (replacement is null) - { - return existingYaml; - } - var trailing = existingYaml.EndsWith('\n') ? string.Empty : "\n"; - return existingYaml + trailing + replacement; - } - // ------------------------------------------------------------------------- // Parse // ------------------------------------------------------------------------- @@ -152,12 +55,6 @@ private static WinappConfig ParseInternal(string yaml) string? currentName = null; var section = Section.None; - // jsBindings sub-state - JsBindingsConfig? js = null; - var jsList = JsListMode.None; - JsBindingsExtraType? currentExtra = null; - bool inClassesList = false; - while ((line = sr.ReadLine()) != null) { // Preserve raw indent for nested-list tracking, then trim for content match. @@ -177,41 +74,11 @@ private static WinappConfig ParseInternal(string yaml) currentName = null; continue; } - // Top-level scalar: cppProjections: . Default true; only - // written by `winapp init` when the npm caller picks "JS only". - if (TryReadScalar(t, "cppProjections:", out var cppProjValue)) - { - if (TryParseBool(cppProjValue, out var b)) - { - cfg.CppProjections = b; - } - section = Section.None; - currentName = null; - jsList = JsListMode.None; - currentExtra = null; - inClassesList = false; - continue; - } - // Accept `jsBindings:` followed by inline comment / trailing - // whitespace — matches SpliceJsBindingsBlock's detection so - // Load() and the splice can never disagree on whether the - // block exists. - if (IsTopLevelKey(t, "jsBindings:")) - { - section = Section.JsBindings; - js = new JsBindingsConfig(); - jsList = JsListMode.None; - currentExtra = null; - inClassesList = false; - continue; - } - // Unknown top-level field → reset section so children don't leak. + // Unknown top-level field → reset section so children don't leak + // into packages/etc. section = Section.None; currentName = null; - jsList = JsListMode.None; - currentExtra = null; - inClassesList = false; continue; } @@ -233,183 +100,12 @@ private static WinappConfig ParseInternal(string yaml) currentName = null; } break; - - case Section.JsBindings: - ParseJsBindingsLine(js!, t, ref jsList, ref currentExtra, ref inClassesList); - break; } } - if (js is not null) - { - cfg.JsBindings = js; - } return cfg; } - private static void ParseJsBindingsLine( - JsBindingsConfig js, - string t, - ref JsListMode listMode, - ref JsBindingsExtraType? currentExtra, - ref bool inClassesList) - { - // Scalar keys reset list state. - if (TryReadScalar(t, "lang:", out var v)) { js.Lang = v; listMode = JsListMode.None; currentExtra = null; inClassesList = false; return; } - if (TryReadScalar(t, "output:", out v)) { js.Output = v; listMode = JsListMode.None; currentExtra = null; inClassesList = false; return; } - - if (IsTopLevelKey(t, "packages:")) - { - listMode = JsListMode.Packages; - currentExtra = null; - inClassesList = false; - return; - } - if (IsTopLevelKey(t, "additionalWinmds:")) - { - listMode = JsListMode.AdditionalWinmds; - currentExtra = null; - inClassesList = false; - return; - } - if (IsTopLevelKey(t, "additionalRefs:")) - { - listMode = JsListMode.AdditionalRefs; - currentExtra = null; - inClassesList = false; - return; - } - if (IsTopLevelKey(t, "skipPackages:")) - { - listMode = JsListMode.SkipPackages; - currentExtra = null; - inClassesList = false; - return; - } - if (IsTopLevelKey(t, "refOnlyPackages:")) - { - listMode = JsListMode.RefOnlyPackages; - currentExtra = null; - inClassesList = false; - return; - } - if (IsTopLevelKey(t, "emitPackages:")) - { - listMode = JsListMode.EmitPackages; - currentExtra = null; - inClassesList = false; - return; - } - if (IsTopLevelKey(t, "extraTypes:")) - { - listMode = JsListMode.ExtraTypes; - currentExtra = null; - inClassesList = false; - return; - } - - if (t.StartsWith("- ", StringComparison.Ordinal) - && s_listSelectors.TryGetValue(listMode, out var getList)) - { - var value = SanitizeScalar(t[2..]); - var list = getList(js); - if (!string.IsNullOrEmpty(value) - && !list.Contains(value, StringComparer.OrdinalIgnoreCase)) - { - list.Add(value); - } - return; - } - - if (listMode == JsListMode.ExtraTypes) - { - // A `- ` IMMEDIATELY followed by a known extraTypes sub-key - // (`namespace:` or `classes:`) anchors a new entry. Without - // this recognition rule we couldn't tell `- classes:` (a new - // entry whose first key is `classes:`) from `- ClassName` (a - // class literally named "ClassName" inside the previous - // entry's classes list) — the parser sees the line already - // left-trimmed, so indent can't disambiguate. Restricting to - // known sub-keys makes the order-independence safe in both - // directions: `- namespace: …` first or `- classes: …` first. - if (t.StartsWith("- ", StringComparison.Ordinal)) - { - var rest = t.Substring(2).TrimStart(); - bool isEntryAnchor = - rest.StartsWith("namespace:", StringComparison.OrdinalIgnoreCase) - || rest.StartsWith("classes:", StringComparison.OrdinalIgnoreCase); - if (isEntryAnchor) - { - currentExtra = new JsBindingsExtraType(); - js.ExtraTypes.Add(currentExtra); - inClassesList = false; - // Re-dispatch the rest of the dash line as a child - // key/value by stripping the dash prefix and falling - // through to the sub-key matchers. - t = rest; - } - } - if (currentExtra is null) - { - return; - } - if (t.StartsWith("namespace:", StringComparison.OrdinalIgnoreCase)) - { - currentExtra.Namespace = SanitizeScalar(t.Substring("namespace:".Length)); - inClassesList = false; - return; - } - if (IsTopLevelKey(t, "classes:")) - { - inClassesList = true; - return; - } - // Inline flow-list form: `classes: [X, Y, Z]` or `classes: [X]`. - if (t.StartsWith("classes:", StringComparison.OrdinalIgnoreCase)) - { - var rest = t.Substring("classes:".Length).Trim(); - if (rest.StartsWith('[')) - { - var end = rest.IndexOf(']'); - if (end > 0) - { - var contents = rest.Substring(1, end - 1); - foreach (var item in contents.Split(',')) - { - var name = SanitizeScalar(item); - if (!string.IsNullOrEmpty(name)) - { - currentExtra.Classes.Add(name); - } - } - inClassesList = false; - return; - } - } - // Scalar form: `classes: SingleClass` (no brackets). - if (!string.IsNullOrEmpty(rest)) - { - var name = SanitizeScalar(rest); - if (!string.IsNullOrEmpty(name)) - { - currentExtra.Classes.Add(name); - } - inClassesList = false; - return; - } - } - if (inClassesList && t.StartsWith("- ", StringComparison.Ordinal)) - { - var name = SanitizeScalar(t[2..]); - if (!string.IsNullOrEmpty(name)) - { - currentExtra.Classes.Add(name); - } - return; - } - } - } - internal static bool TryReadScalar(string t, string prefix, out string value) { if (t.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) @@ -447,11 +143,11 @@ internal static bool TryParseBool(string value, out bool result) // Trims surrounding whitespace, strips an unquoted trailing `# comment`, // then strips a single pair of matching surrounding quotes. Mirrors what // a YAML parser would do for plain / single- / double-quoted scalars - // (sufficient for the small jsBindings grammar we parse by hand). + // (sufficient for the small grammar we parse by hand). // - // `output: bindings/winrt # generated` → `bindings/winrt` - // `name: "weird # name"` → `weird # name` - // `path: 'C:\foo'` → `C:\foo` + // `version: 1.0.0 # pinned` → `1.0.0` + // `name: "weird # name"` → `weird # name` + // `name: 'O''Brien'` → `O'Brien` internal static string SanitizeScalar(string raw) { if (string.IsNullOrEmpty(raw)) @@ -534,9 +230,8 @@ private static int LeadingSpaceCount(string line) return i; } - // Matches a top-level key like `packages:` or `jsBindings:` with any - // trailing whitespace or inline `# comment`. Used by both Parse and - // SpliceJsBindingsBlock so they never disagree on block presence. + // Matches a top-level key like `packages:` with any trailing whitespace + // or inline `# comment`. internal static bool IsTopLevelKey(string trimmedLine, string key) { if (!trimmedLine.StartsWith(key, StringComparison.OrdinalIgnoreCase)) @@ -551,22 +246,7 @@ internal static bool IsTopLevelKey(string trimmedLine, string key) return rest.IsEmpty || rest[0] == '#'; } - private enum Section { None, Packages, JsBindings } - private enum JsListMode { None, Packages, ExtraTypes, AdditionalWinmds, AdditionalRefs, SkipPackages, RefOnlyPackages, EmitPackages } - - // Table-driven dispatch for the string-list jsBindings sub-keys (everything - // except ExtraTypes, whose entries are objects). Keeps ParseJsBindingsLine - // honest: adding a new list-mode is one table entry instead of an enum arm - // + a 9-line near-duplicate if-block that could silently drift. - private static readonly Dictionary>> s_listSelectors = new() - { - [JsListMode.Packages] = js => js.Packages, - [JsListMode.AdditionalWinmds] = js => js.AdditionalWinmds, - [JsListMode.AdditionalRefs] = js => js.AdditionalRefs, - [JsListMode.SkipPackages] = js => js.SkipPackages, - [JsListMode.RefOnlyPackages] = js => js.RefOnlyPackages, - [JsListMode.EmitPackages] = js => js.EmitPackages, - }; + private enum Section { None, Packages } // ------------------------------------------------------------------------- // Render @@ -582,94 +262,9 @@ private static string Stringify(WinappConfig cfg) sb.AppendLine($" version: {QuoteScalar(p.Version)}"); } - // Only emit cppProjections when it diverges from the default (true) so - // existing yamls stay clean and round-trip unchanged. - if (!cfg.CppProjections) - { - sb.AppendLine(); - sb.AppendLine("cppProjections: false"); - } - - if (cfg.JsBindings is { } js) - { - sb.AppendLine(); - AppendJsBindingsBlock(sb, js); - } return sb.ToString(); } - // Render the jsBindings: block. Shared by Stringify and SpliceJsBindingsBlock. - private static void AppendJsBindingsBlock(StringBuilder sb, JsBindingsConfig js) - { - sb.AppendLine("jsBindings:"); - sb.AppendLine($" lang: {QuoteScalar(js.Lang)}"); - sb.AppendLine($" output: {QuoteScalar(js.Output)}"); - if (js.Packages.Count > 0) - { - sb.AppendLine(" packages:"); - foreach (var pkg in js.Packages) - { - sb.AppendLine($" - {QuoteScalar(pkg)}"); - } - } - if (js.AdditionalWinmds.Count > 0) - { - sb.AppendLine(" additionalWinmds:"); - foreach (var path in js.AdditionalWinmds) - { - sb.AppendLine($" - {QuoteScalar(path)}"); - } - } - if (js.AdditionalRefs.Count > 0) - { - sb.AppendLine(" additionalRefs:"); - foreach (var path in js.AdditionalRefs) - { - sb.AppendLine($" - {QuoteScalar(path)}"); - } - } - if (js.SkipPackages.Count > 0) - { - sb.AppendLine(" skipPackages:"); - foreach (var pkg in js.SkipPackages) - { - sb.AppendLine($" - {QuoteScalar(pkg)}"); - } - } - if (js.RefOnlyPackages.Count > 0) - { - sb.AppendLine(" refOnlyPackages:"); - foreach (var pkg in js.RefOnlyPackages) - { - sb.AppendLine($" - {QuoteScalar(pkg)}"); - } - } - if (js.EmitPackages.Count > 0) - { - sb.AppendLine(" emitPackages:"); - foreach (var pkg in js.EmitPackages) - { - sb.AppendLine($" - {QuoteScalar(pkg)}"); - } - } - if (js.ExtraTypes.Count > 0) - { - sb.AppendLine(" extraTypes:"); - foreach (var et in js.ExtraTypes) - { - sb.AppendLine($" - namespace: {QuoteScalar(et.Namespace)}"); - if (et.Classes.Count > 0) - { - sb.AppendLine(" classes:"); - foreach (var cls in et.Classes) - { - sb.AppendLine($" - {QuoteScalar(cls)}"); - } - } - } - } - } - // Quote a YAML scalar with single quotes when the raw value would be // re-parsed incorrectly by our (or any other) plain-scalar reader. We // bias toward over-quoting because the cost is cosmetic and the cost diff --git a/src/winapp-CLI/WinApp.Cli/Services/WinmdsLockfileService.cs b/src/winapp-CLI/WinApp.Cli/Services/WinmdsLockfileService.cs index 4dc496f7..c9720397 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/WinmdsLockfileService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/WinmdsLockfileService.cs @@ -144,8 +144,10 @@ await File.WriteAllTextAsync( } } - // Bucket winmds by package, classify. Paths off the NuGet cache layout - // are dropped. + // Bucket winmds by package. Paths off the NuGet cache layout are dropped. + // Classification (emit/refOnly/skip) is intentionally NOT recorded — that + // policy lives in the npm wrapper so changing it doesn't force a native + // CLI rebuild + redeploy. internal static WinmdsLockfile BuildLockfile( IReadOnlyDictionary usedVersions, IReadOnlyList discoveredWinmds, @@ -157,7 +159,7 @@ internal static WinmdsLockfile BuildLockfile( var winmdsByPackage = new Dictionary>(StringComparer.OrdinalIgnoreCase); foreach (var w in discoveredWinmds) { - var pkgIdLc = JsBindingsPresets.ExtractPackageIdFromPath(w.FullName, nugetCacheDir.FullName); + var pkgIdLc = ExtractPackageIdFromPath(w.FullName, nugetCacheDir.FullName); if (pkgIdLc is null) { continue; @@ -175,17 +177,10 @@ internal static WinmdsLockfile BuildLockfile( { var pkgIdLc = name.ToLowerInvariant(); winmdsByPackage.TryGetValue(pkgIdLc, out var winmds); - var category = JsBindingsPresets.ClassifyPackage(name) switch - { - WinmdPackageCategory.Skip => "skip", - WinmdPackageCategory.RefOnly => "refOnly", - _ => "emit", - }; packages.Add(new WinmdsLockfilePackage { Name = name, Version = version, - Category = category, Winmds = winmds is null ? new List() : winmds.OrderBy(p => p, StringComparer.OrdinalIgnoreCase).ToList(), @@ -204,4 +199,21 @@ internal static WinmdsLockfile BuildLockfile( Packages = packages, }; } + + // NuGet cache layout: `///...`. + // Returns the lowercased package id segment, or null if the file isn't + // under the cache (e.g. a user-supplied `additionalWinmds` path). + private static string? ExtractPackageIdFromPath(string winmdFullPath, string nugetCacheDir) + { + var normCache = Path.TrimEndingDirectorySeparator(Path.GetFullPath(nugetCacheDir)); + var normWinmd = Path.GetFullPath(winmdFullPath); + if (!normWinmd.StartsWith(normCache + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) + && !normWinmd.StartsWith(normCache + Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + var rel = normWinmd.Substring(normCache.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var firstSep = rel.IndexOfAny(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }); + return firstSep <= 0 ? null : rel.Substring(0, firstSep).ToLowerInvariant(); + } } diff --git a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Init.cs b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Init.cs index a86b87f2..7c6520ba 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Init.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Init.cs @@ -12,10 +12,14 @@ namespace WinApp.Cli.Services; // // Owns the logic that decides whether we're "init" or "restore", loads or // scaffolds winapp.yaml, walks the user through SDK / manifest / dev-mode -// prompts on first run, validates a .NET project's TargetFramework, and -// (for npm callers) prompts which bindings — C++ / JS/TS / Both — to wire -// into winapp.yaml. Result tuple is consumed by SetupWorkspaceAsync to -// decide the rest of the flow. +// prompts on first run, and validates a .NET project's TargetFramework. +// Result tuple is consumed by SetupWorkspaceAsync to decide the rest of +// the flow. +// +// Note: the npm-caller bindings prompt (add JS/TS bindings?) lives entirely +// in the @microsoft/winapp npm wrapper. The wrapper persists its decision +// in package.json (`"winapp": { "jsBindings": {...} }`) after this command +// returns; the native CLI has no awareness of JS bindings. internal partial class WorkspaceSetupService { private async Task<(int ReturnCode, WinappConfig? Config, bool HadExistingConfig, bool ShouldGenerateManifest, ManifestGenerationInfo? ManifestGenerationInfo, bool ShouldEnableDeveloperMode, string? RecommendedTfm)> InitializeConfigurationAsync(WorkspaceSetupOptions options, bool isDotNetProject, FileInfo? csprojFile, CancellationToken cancellationToken) @@ -50,7 +54,7 @@ internal partial class WorkspaceSetupService { config = configService.Load(); - if (config.Packages.Count == 0 && options.RequireExistingConfig && config.JsBindings is null) + if (config.Packages.Count == 0 && options.RequireExistingConfig) { logger.LogInformation("{UISymbol} winapp.yaml found but contains no packages. Nothing to restore.", UiSymbols.Note); shouldEnableDeveloperMode = await AskShouldEnableDeveloperModeAsync(options, cancellationToken); @@ -107,68 +111,6 @@ internal partial class WorkspaceSetupService } } - // npm-caller bindings prompt: ask whether to wire C++ projections, - // JS/TS bindings, or both into winapp.yaml. Fills options.AddJsBindings - // and options.SkipCppProjections so the rest of the flow knows what - // to generate. No-op when not running via the npm shim, or when an - // existing yaml already declares jsBindings:. - var bindingsKind = await AskBindingsKindAsync(options, config, isDotNetProject, cancellationToken); - options.AddJsBindings = bindingsKind is BindingsKind.JsOnly or BindingsKind.Both; - options.SkipCppProjections = bindingsKind == BindingsKind.JsOnly; - - // JS/TS bindings target Node/Electron hosts via dynwinrt; .NET projects - // already have first-class WinRT projections through CsWinRT and the - // codegen does not produce a .NET-consumable surface. AskBindingsKindAsync - // already silently downgrades .NET projects to CppOnly, so this guard only - // catches the case where an existing yaml has a hand-edited `jsBindings:` - // block on a .NET project. - if (options.AddJsBindings && isDotNetProject) - { - logger.LogError( - "{UISymbol} JS/TS bindings are not supported on .NET projects — the codegen targets Node/Electron via dynwinrt, and .NET projects already get WinRT via CsWinRT. " + - "Remove the `jsBindings:` block from winapp.yaml, or re-run from a non-.NET project.", - UiSymbols.Error); - return (1, config, hadExistingConfig, shouldGenerateManifest, manifestGenerationInfo, shouldEnableDeveloperMode, recommendedTfm); - } - - // Re-check after AskSdkInstallModeAsync: if a hand-edited yaml has - // jsBindings: but the user picked --setup-sdks none, codegen has no - // winmd source. AskBindingsKindAsync already silently downgrades the - // SDK-None case for fresh init, so this guard only catches the - // hand-edited-yaml + SDK-None conflict. - if (options.AddJsBindings && options.SdkInstallMode == SdkInstallMode.None) - { - logger.LogError( - "{UISymbol} JS/TS bindings need SDK packages, but the SDK install mode was set to 'none'. " + - "Remove the `jsBindings:` block from winapp.yaml, or re-run with a non-'none' SDK mode (stable / preview / experimental).", - UiSymbols.Error); - return (1, config, hadExistingConfig, shouldGenerateManifest, manifestGenerationInfo, shouldEnableDeveloperMode, recommendedTfm); - } - - // Inject a default jsBindings: block (empty packages: ⇒ all WinAppSDK) - // when the prompt opted in and the existing yaml hasn't declared one. - if (options.AddJsBindings && config != null && config.JsBindings is null) - { - config.JsBindings = new JsBindingsConfig(); - logger.LogDebug( - "{UISymbol} Added default jsBindings block (lang={Lang}, output={Output}); empty packages ⇒ full WinAppSDK.", - UiSymbols.New, - config.JsBindings.Lang, - config.JsBindings.Output); - - // Note: @microsoft/dynwinrt is added as a production dep AFTER - // bindings succeed (JsBindingsWorkspaceService.RunAsync). Doing - // it here would leave package.json mutated if codegen failed. - } - - // Persist cppProjections: false when the JS-only choice diverges from - // the model default (true). Init's later save path round-trips this - // field through WinappConfigDocument. - if (options.SkipCppProjections && config != null) - { - config.CppProjections = false; - } - // .NET: Validate TargetFramework (interactive) if (isDotNetProject && csprojFile != null) { diff --git a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Options.cs b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Options.cs index ec8e3757..a2ad89b1 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Options.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Options.cs @@ -17,13 +17,4 @@ internal class WorkspaceSetupOptions public bool RequireExistingConfig { get; set; } public bool ForceLatestBuildTools { get; set; } public bool ConfigOnly { get; set; } - - // Enable JS/TS bindings generation in Step 5.5 of setup. Populated by the - // npm-caller prompt in WorkspaceSetupService; no CLI flag exposes this. - public bool AddJsBindings { get; set; } - - // Skip cppwinrt headers/libs/runtimes/projection generation. Populated by the - // npm-caller prompt when the user picks "JS only" so pure-Node projects don't - // pay the ~130MB / ~20s C++ projection cost. - public bool SkipCppProjections { get; set; } } diff --git a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Prompts.cs b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Prompts.cs index a072a45d..d72499c5 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Prompts.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Prompts.cs @@ -237,102 +237,4 @@ await ansiConsole.Status() logger.LogInformation("{Message}", message); return await work(cancellationToken); } - - // Result of the npm-caller bindings prompt. - private enum BindingsKind - { - CppOnly, - JsOnly, - Both, - } - - // Asks the user (npm caller only) which bindings to generate for this - // workspace: C++ projections, JS/TS bindings, or both. Defaults to Both - // under --use-defaults. Returns CppOnly (the historical default) for - // non-npm callers so winget / standalone-CLI users see no behavior change. - // .NET projects also silently get CppOnly — they can't consume dynwinrt - // bindings anyway, so asking would only offer one valid answer. - private async Task AskBindingsKindAsync(WorkspaceSetupOptions options, WinappConfig? existingConfig, bool isDotNetProject, CancellationToken cancellationToken) - { - // Restore (winapp restore) never re-prompts: it respects whatever the - // existing yaml already declares. - if (options.RequireExistingConfig) - { - return BindingsKindFromConfig(existingConfig); - } - - // Standalone CLI (winget / native binary) keeps its current C++ default. - var caller = Environment.GetEnvironmentVariable("WINAPP_CLI_CALLER"); - if (!string.Equals(caller, "nodejs-package", StringComparison.Ordinal)) - { - return BindingsKind.CppOnly; - } - - // Existing yaml that already declares jsBindings: — don't change the - // user's earlier choice. Map it back to a kind so callers can still - // gate on AddJsBindings / SkipCppProjections. The .NET guard in - // SetupWorkspaceAsync will reject this combination with an actionable - // message; don't pre-empt it here. - if (existingConfig?.JsBindings is not null) - { - return BindingsKindFromConfig(existingConfig); - } - - // .NET projects can't consume dynwinrt bindings — skip the prompt - // entirely rather than asking a question with only one valid answer - // (and rather than tripping the .NET guard when --use-defaults sets - // the default to Both). - if (isDotNetProject) - { - return BindingsKind.CppOnly; - } - - // JS bindings need SDK packages for the winmd source. If the user - // (or sample tests like rust-app) picked `--setup-sdks none`, there's - // nothing for codegen to consume. Silently downgrade rather than - // tripping the SDK-None guard with --use-defaults → Both. - if (options.SdkInstallMode == SdkInstallMode.None) - { - return BindingsKind.CppOnly; - } - - // Non-interactive: default to Both so `npx winapp init --use-defaults` - // (sample tests, CI) wires up everything the npm wrapper enables. - if (options.UseDefaults) - { - return BindingsKind.Both; - } - - var choices = new[] - { - "Both C++ and JS/TS bindings (default)", - "JS/TS bindings only", - "C++ projections only", - }; - - ansiConsole.WriteLine("Select which bindings to generate:"); - var pick = await ansiConsole.PromptAsync( - new SelectionPrompt().AddChoices(choices), - cancellationToken); - - ansiConsole.Cursor.MoveUp(); - ansiConsole.Write("\x1b[2K"); // Clear line - ansiConsole.MarkupLine($"Bindings: [underline]{Markup.Remove(pick)}[/]"); - - return pick switch - { - "JS/TS bindings only" => BindingsKind.JsOnly, - "C++ projections only" => BindingsKind.CppOnly, - _ => BindingsKind.Both, - }; - } - - private static BindingsKind BindingsKindFromConfig(WinappConfig? config) - { - if (config?.JsBindings is null) - { - return BindingsKind.CppOnly; - } - return config.CppProjections ? BindingsKind.Both : BindingsKind.JsOnly; - } } diff --git a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.cs b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.cs index a6598c95..d6008b46 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.cs @@ -11,17 +11,20 @@ namespace WinApp.Cli.Services; // Shared service for setting up winapp workspaces. Split into partials: -// - this file: orchestration (SetupWorkspaceAsync, init/restore flow, JS bindings step glue) +// - this file: orchestration (SetupWorkspaceAsync, init/restore flow) // - WorkspaceSetupService.Options.cs: option DTO (WorkspaceSetupOptions) -// - WorkspaceSetupService.Prompts.cs: Spectre.Console prompts (SDK choice, manifest, dev mode, .csproj picker, bindings kind) +// - WorkspaceSetupService.Prompts.cs: Spectre.Console prompts (SDK choice, manifest, dev mode, .csproj picker) // - WorkspaceSetupService.Msix.cs: Windows App SDK runtime MSIX install / NuGet-cache discovery +// +// JS/TS bindings orchestration (codegen, runtime-dep injection, prompt) lives +// entirely in the @microsoft/winapp npm wrapper, which calls this native CLI +// for `init`/`restore` and reads the lockfile written by Step 5. internal partial class WorkspaceSetupService( IConfigService configService, IWinappDirectoryService winappDirectoryService, IPackageInstallationService packageInstallationService, IBuildToolsService buildToolsService, ICppWinrtService cppWinrtService, - IJsBindingsWorkspaceService jsBindingsWorkspaceService, IPackageLayoutService packageLayoutService, IWinmdsLockfileService winmdsLockfileService, IPackageRegistrationService packageRegistrationService, @@ -92,25 +95,6 @@ public async Task SetupWorkspaceAsync(WorkspaceSetupOptions options, Cancel logger.LogInformation("{UISymbol} {PackageName} = {PackageVersion}", UiSymbols.Bullet, pkg.Name, pkg.Version); } } - - // Persist the prompt's freshly-injected jsBindings (and any - // cppProjections override) even under --config-only. - if (options.AddJsBindings && config.JsBindings is not null) - { - if (options.SkipCppProjections) - { - // SaveJsBindingsOnly only splices jsBindings; full-save - // is the simplest way to round-trip cppProjections too - // (loses comments — acceptable trade-off for a niche - // --config-only + JS-only path). - configService.Save(config); - } - else - { - configService.SaveJsBindingsOnly(config); - } - logger.LogDebug("{UISymbol} Persisted updated configuration with jsBindings → {ConfigPath}", UiSymbols.Save, configService.ConfigPath); - } } else if (options.SdkInstallMode != SdkInstallMode.None) { @@ -134,12 +118,7 @@ public async Task SetupWorkspaceAsync(WorkspaceSetupOptions options, Cancel } } - var finalConfig = new WinappConfig - { - // Preserve JsBindings + CppProjections across re-init. - JsBindings = config?.JsBindings, - CppProjections = config?.CppProjections ?? true, - }; + var finalConfig = new WinappConfig(); foreach (var kvp in defaultVersions) { finalConfig.SetVersion(kvp.Key, kvp.Value); @@ -167,15 +146,6 @@ public async Task SetupWorkspaceAsync(WorkspaceSetupOptions options, Cancel } // else: SdkInstallMode == None and no existing config - nothing to do - // --config-only skips the bindings step entirely, so the M13 - // "defer pkg.json mutation until codegen succeeds" rule has - // nothing to defer past. Update package.json here so the - // npm-caller path still gets @microsoft/dynwinrt wired up. - if (options.AddJsBindings && config?.JsBindings is not null) - { - jsBindingsWorkspaceService.EnsureRuntimeDependencyAndPrintHint(options.BaseDirectory); - } - logger.LogInformation("Configuration-only operation completed"); return 0; } @@ -527,64 +497,52 @@ await taskContext.AddSubTaskAsync("Configuring developer mode", async (taskConte } // Step 5: Run cppwinrt and set up projections. - // Gated by config.CppProjections (default true): JS-only workspaces - // (config.CppProjections == false) skip cppwinrt + headers/libs/runtimes - // but still need .winmd discovery + lockfile for the JS bindings step. - var generateCppProjections = config?.CppProjections != false; - FileInfo? cppWinrtExe = null; - if (generateCppProjections) + var cppWinrtExe = cppWinrtService.FindCppWinrtExe(nugetCacheDir, usedVersions); + if (cppWinrtExe is null) { - cppWinrtExe = cppWinrtService.FindCppWinrtExe(nugetCacheDir, usedVersions); - if (cppWinrtExe is null) - { - return (1, "cppwinrt.exe not found in installed packages."); - } + return (1, "cppwinrt.exe not found in installed packages."); + } - taskContext.AddDebugMessage($"{UiSymbols.Tools} Using cppwinrt tool → {cppWinrtExe}"); + taskContext.AddDebugMessage($"{UiSymbols.Tools} Using cppwinrt tool → {cppWinrtExe}"); - // Copy headers, libs, runtimes - taskContext.UpdateSubStatus("Copying headers"); - packageLayoutService.CopyIncludesFromPackages(nugetCacheDir, includeOut, usedVersions); - taskContext.AddDebugMessage($"{UiSymbols.Check} Headers ready → {includeOut}"); + // Copy headers, libs, runtimes + taskContext.UpdateSubStatus("Copying headers"); + packageLayoutService.CopyIncludesFromPackages(nugetCacheDir, includeOut, usedVersions); + taskContext.AddDebugMessage($"{UiSymbols.Check} Headers ready → {includeOut}"); - taskContext.UpdateSubStatus("Copying import libraries"); - packageLayoutService.CopyLibsAllArch(nugetCacheDir, libRoot, usedVersions); - var libArchs = libRoot.Exists ? string.Join(", ", libRoot.EnumerateDirectories().Select(d => d.Name)) : "(none)"; - taskContext.AddDebugMessage($"{UiSymbols.Books} Import libs ready for archs: {libArchs}"); + taskContext.UpdateSubStatus("Copying import libraries"); + packageLayoutService.CopyLibsAllArch(nugetCacheDir, libRoot, usedVersions); + var libArchs = libRoot.Exists ? string.Join(", ", libRoot.EnumerateDirectories().Select(d => d.Name)) : "(none)"; + taskContext.AddDebugMessage($"{UiSymbols.Books} Import libs ready for archs: {libArchs}"); - taskContext.UpdateSubStatus("Copying runtime binaries"); - packageLayoutService.CopyRuntimesAllArch(nugetCacheDir, binRoot, usedVersions); - var binArchs = binRoot.Exists ? string.Join(", ", binRoot.EnumerateDirectories().Select(d => d.Name)) : "(none)"; - taskContext.AddDebugMessage($"{UiSymbols.Check} Runtime binaries ready for archs: {binArchs}"); + taskContext.UpdateSubStatus("Copying runtime binaries"); + packageLayoutService.CopyRuntimesAllArch(nugetCacheDir, binRoot, usedVersions); + var binArchs = binRoot.Exists ? string.Join(", ", binRoot.EnumerateDirectories().Select(d => d.Name)) : "(none)"; + taskContext.AddDebugMessage($"{UiSymbols.Check} Runtime binaries ready for archs: {binArchs}"); - // Copy Windows App SDK license - try + // Copy Windows App SDK license + try + { + if (usedVersions.TryGetValue(BuildToolsService.WINAPP_SDK_PACKAGE, out var wasdkVersion)) { - if (usedVersions.TryGetValue(BuildToolsService.WINAPP_SDK_PACKAGE, out var wasdkVersion)) + var pkgDir = nugetService.GetNuGetPackageDir(BuildToolsService.WINAPP_SDK_PACKAGE, wasdkVersion); + var licenseSrc = Path.Combine(pkgDir.FullName, "license.txt"); + if (File.Exists(licenseSrc)) { - var pkgDir = nugetService.GetNuGetPackageDir(BuildToolsService.WINAPP_SDK_PACKAGE, wasdkVersion); - var licenseSrc = Path.Combine(pkgDir.FullName, "license.txt"); - if (File.Exists(licenseSrc)) - { - var shareDir = Path.Combine(localWinappDir.FullName, "share", BuildToolsService.WINAPP_SDK_PACKAGE); - Directory.CreateDirectory(shareDir); - var licenseDst = Path.Combine(shareDir, "copyright"); - File.Copy(licenseSrc, licenseDst, overwrite: true); - taskContext.AddDebugMessage($"{UiSymbols.Check} License copied → {licenseDst}"); - } + var shareDir = Path.Combine(localWinappDir.FullName, "share", BuildToolsService.WINAPP_SDK_PACKAGE); + Directory.CreateDirectory(shareDir); + var licenseDst = Path.Combine(shareDir, "copyright"); + File.Copy(licenseSrc, licenseDst, overwrite: true); + taskContext.AddDebugMessage($"{UiSymbols.Check} License copied → {licenseDst}"); } } - catch (Exception ex) - { - taskContext.AddDebugMessage($"{UiSymbols.Note} Failed to copy license: {ex.Message}"); - } } - else + catch (Exception ex) { - taskContext.AddDebugMessage($"{UiSymbols.Skip} cppProjections: false → skipping cppwinrt + headers/libs/runtimes (JS-only workspace)."); + taskContext.AddDebugMessage($"{UiSymbols.Note} Failed to copy license: {ex.Message}"); } - // Collect winmd inputs (unconditional: JS bindings need the lockfile too). + // Collect winmd inputs (the npm-wrapper JS bindings pipeline reads the lockfile too). taskContext.UpdateSubStatus("Searching for .winmd metadata"); var winmds = packageLayoutService.FindWinmds(nugetCacheDir, usedVersions).ToList(); taskContext.AddDebugMessage($"{UiSymbols.Search} Found {winmds.Count} .winmd"); @@ -603,13 +561,10 @@ await taskContext.AddSubTaskAsync("Configuring developer mode", async (taskConte await winmdsLockfileService.WriteAsync( localWinappDir, usedVersions, winmds, nugetCacheDir, yamlHash, cancellationToken); - if (generateCppProjections) - { - // Run cppwinrt - taskContext.UpdateSubStatus("Generating C++/WinRT projections"); - await cppWinrtService.RunWithRspAsync(cppWinrtExe!, winmds, includeOut, localWinappDir, taskContext, cancellationToken: cancellationToken); - taskContext.AddDebugMessage($"{UiSymbols.Check} C++/WinRT headers generated → {includeOut}"); - } + // Run cppwinrt + taskContext.UpdateSubStatus("Generating C++/WinRT projections"); + await cppWinrtService.RunWithRspAsync(cppWinrtExe, winmds, includeOut, localWinappDir, taskContext, cancellationToken: cancellationToken); + taskContext.AddDebugMessage($"{UiSymbols.Check} C++/WinRT headers generated → {includeOut}"); partialResult = await taskContext.AddSubTaskAsync("Setting up tools", async (taskContext, cancellationToken) => { @@ -644,9 +599,7 @@ await winmdsLockfileService.WriteAsync( return partialResult; } - return (0, generateCppProjections - ? "SDK and Windows App SDK packages downloaded and C++ headers generated in [underline].winapp[/]" - : "SDK and Windows App SDK packages downloaded in [underline].winapp[/] (cppProjections: false → C++ headers skipped)"); + return (0, "SDK and Windows App SDK packages downloaded and C++ headers generated in [underline].winapp[/]"); }, cancellationToken); if (partialResult.Item1 != 0) @@ -660,15 +613,6 @@ await winmdsLockfileService.WriteAsync( } } - // Step 5.5: Generate JS/TS bindings (opt-in via jsBindings: in winapp.yaml) - var jsBindingsStep = await MaybeRunJsBindingsStepAsync( - config, usedVersions, nugetCacheDir, localWinappDir, - options, taskContext, cancellationToken); - if (jsBindingsStep is { } failed) - { - return failed; - } - // Install Windows App SDK Runtime (shared: both .NET and native paths) if (options.SdkInstallMode != SdkInstallMode.None) { @@ -739,12 +683,7 @@ await taskContext.AddSubTaskAsync("Installing Windows App SDK Runtime", async (t await taskContext.AddSubTaskAsync("Saving configuration", (taskContext, cancellationToken) => { // Setup: Save winapp.yaml with used versions - var finalConfig = new WinappConfig - { - // Preserve JsBindings + CppProjections so the persisted yaml round-trips. - JsBindings = config?.JsBindings, - CppProjections = config?.CppProjections ?? true, - }; + var finalConfig = new WinappConfig(); // only from SDK_PACKAGES var versionsToSave = usedVersions .Where(kvp => NugetService.SDK_PACKAGES.Contains(kvp.Key, StringComparer.OrdinalIgnoreCase)) @@ -836,52 +775,6 @@ await taskContext.AddSubTaskAsync("Updating Directory.Packages.props", (taskCont }, cancellationToken); } - // Runs the JS-bindings step when prerequisites are present. - // Returns null on skip/success; non-zero tuple on failure (forwarded to caller). - // Internal so unit tests can drive it with a fake IJsBindingsWorkspaceService. - internal async Task<(int, string)?> MaybeRunJsBindingsStepAsync( - WinappConfig? config, - Dictionary? usedVersions, - DirectoryInfo? nugetCacheDir, - DirectoryInfo? localWinappDir, - WorkspaceSetupOptions options, - TaskContext taskContext, - CancellationToken cancellationToken) - { - if (config?.JsBindings is null - || usedVersions is null - || nugetCacheDir is null - || localWinappDir is null) - { - return null; - } - - var jsBindingsResult = await taskContext.AddSubTaskAsync("Generating JS bindings", async (taskContext, cancellationToken) => - { - var orchResult = await jsBindingsWorkspaceService.RunAsync( - new JsBindingsOrchestrationContext - { - JsBindingsConfig = config.JsBindings, - WinappConfig = config, - WorkspaceDir = options.BaseDirectory, - LocalWinappDir = localWinappDir, - NugetCacheDir = nugetCacheDir, - UsedVersions = usedVersions, - }, - taskContext, - cancellationToken); - return (orchResult.ExitCode, orchResult.Message); - }, cancellationToken); - - // Propagate failure so init doesn't report success while shipping - // a broken workspace. - if (jsBindingsResult.Item1 != 0) - { - return jsBindingsResult; - } - return null; - } - private async Task SetupManifestSubTaskAsync(WorkspaceSetupOptions options, bool shouldGenerateManifest, ManifestGenerationInfo? manifestGenerationInfo, TaskContext taskContext, CancellationToken cancellationToken) { await taskContext.AddSubTaskAsync("Generating Manifest and Assets", async (taskContext, cancellationToken) => diff --git a/src/winapp-npm/README.md b/src/winapp-npm/README.md index 4aca8c9f..2c60a1ab 100644 --- a/src/winapp-npm/README.md +++ b/src/winapp-npm/README.md @@ -40,7 +40,7 @@ npx winapp --help **Setup Commands:** - [`init`](https://github.com/microsoft/WinAppCli/blob/main/docs/usage.md#init) - Initialize project with Windows SDK and App SDK -- [`restore`](https://github.com/microsoft/WinAppCli/blob/main/docs/usage.md#restore) - Restore packages and dependencies (also runs the bindings step when `jsBindings:` is declared in `winapp.yaml`) +- [`restore`](https://github.com/microsoft/WinAppCli/blob/main/docs/usage.md#restore) - Restore packages and dependencies (also runs the bindings step when the `winapp.jsBindings` namespace is declared in `package.json`) - [`update`](https://github.com/microsoft/WinAppCli/blob/main/docs/usage.md#update) - Update packages and dependencies to latest versions **App Identity & Debugging:** diff --git a/src/winapp-npm/src/cli.ts b/src/winapp-npm/src/cli.ts index 3b9f804f..1156cd34 100644 --- a/src/winapp-npm/src/cli.ts +++ b/src/winapp-npm/src/cli.ts @@ -4,8 +4,18 @@ import { generateCppAddonFiles } from './cpp-addon-utils'; import { generateCsAddonFiles } from './cs-addon-utils'; import { addElectronDebugIdentity, clearElectronDebugIdentity } from './msix-utils'; import { getWinappCliPath, callWinappCli, callWinappCliCapture, WINAPP_CLI_CALLER_VALUE } from './winapp-cli-utils'; +import { askBindingsKind, parseSetupSdksArg } from './jsbindings/init-prompt'; +import { + readJsBindingsConfig, + writeJsBindingsConfig, + defaultJsBindingsConfig, + hasJsBindings, +} from './jsbindings/package-json-config'; +import { runJsBindingsPipeline } from './jsbindings/orchestrator'; +import { getLockfilePath, LOCKFILE_NAME } from './jsbindings/lockfile-reader'; import { spawn } from 'child_process'; import * as fs from 'fs'; +import * as path from 'path'; // CLI name - change this to rebrand the tool const CLI_NAME = 'winapp'; @@ -13,6 +23,14 @@ const CLI_NAME = 'winapp'; // Commands that should be handled by Node.js (everything else goes to winapp-cli) const NODE_ONLY_COMMANDS = new Set(['node']); +// Commands the npm wrapper intercepts to add pre-/post-native hooks +// (currently: JS bindings prompt + orchestration). +const INTERCEPTED_COMMANDS = new Set(['init', 'restore']); + +// argv flags that mean "skip every interactive wrapper hook" (help / completions +// / version are routed straight to the native CLI without prompting). +const HELP_FLAGS = new Set(['--help', '-h', '-?', '/?']); + interface ParsedArgs { help?: boolean; name?: string; @@ -65,6 +83,30 @@ export async function main(): Promise { return; } + // Intercept init/restore so we can run the JS bindings pre-/post-hooks + // around the native command. Help / completion flags bypass the hook. + // + // Fast-path: `init --setup-sdks none` has no JS bindings to wire up + // (the dynwinrt codegen needs SDK winmds to compile against), so we + // pass it straight through to the native CLI. This preserves the + // pre-wrapper UX exactly — no extra yaml read, no informational log + // line, no behaviour change. Users who want to refresh existing JS + // bindings should run `winapp restore` (which is still intercepted). + if (INTERCEPTED_COMMANDS.has(command) && !commandArgs.some((a) => HELP_FLAGS.has(a))) { + if (command === 'init') { + if (parseSetupSdksArg(commandArgs) === 'none') { + await callWinappCli(args, { exitOnError: true }); + return; + } + await handleInit(commandArgs); + return; + } + if (command === 'restore') { + await handleRestore(commandArgs); + return; + } + } + // Route everything else to winapp-cli await callWinappCli(args, { exitOnError: true }); } catch (error) { @@ -86,7 +128,12 @@ async function handleNodeCommand(command: string, args: string[]): Promise // Node.js wrapper-only commands that should appear in completions const NODE_WRAPPER_COMMANDS = ['node']; -const NODE_SUBCOMMANDS = ['create-addon', 'add-electron-debug-identity', 'clear-electron-debug-identity']; +const NODE_SUBCOMMANDS = [ + 'create-addon', + 'add-electron-debug-identity', + 'clear-electron-debug-identity', + 'generate-bindings', +]; /** * Handle completion requests by forwarding to the native CLI and augmenting @@ -206,12 +253,14 @@ async function showCombinedHelp(): Promise { console.log(' node create-addon Generate native addon files for Electron'); console.log(' node add-electron-debug-identity Add package identity to Electron debug process'); console.log(' node clear-electron-debug-identity Remove package identity from Electron debug process'); + console.log(' node generate-bindings Regenerate JS/TypeScript bindings from package.json + cached winmds'); console.log(''); console.log('Examples:'); console.log(` ${CLI_NAME} node create-addon --name myAddon`); console.log(` ${CLI_NAME} node create-addon --template cs --name myAddon`); console.log(` ${CLI_NAME} node add-electron-debug-identity`); console.log(` ${CLI_NAME} node clear-electron-debug-identity`); + console.log(` ${CLI_NAME} node generate-bindings`); } async function showVersion(): Promise { @@ -270,6 +319,7 @@ async function handleNode(args: string[]): Promise { console.log(' create-addon Generate native addon files for Electron'); console.log(' add-electron-debug-identity Add package identity to Electron debug process'); console.log(' clear-electron-debug-identity Remove package identity from Electron debug process'); + console.log(' generate-bindings Regenerate JS/TypeScript bindings (no NuGet/cppwinrt restore)'); console.log(''); console.log('Examples:'); console.log(` ${CLI_NAME} node create-addon --help`); @@ -277,6 +327,7 @@ async function handleNode(args: string[]): Promise { console.log(` ${CLI_NAME} node create-addon --name myCsAddon --template cs`); console.log(` ${CLI_NAME} node add-electron-debug-identity`); console.log(` ${CLI_NAME} node clear-electron-debug-identity`); + console.log(` ${CLI_NAME} node generate-bindings`); console.log(''); console.log(`Use "${CLI_NAME} node --help" for detailed help on each subcommand.`); return; @@ -298,6 +349,10 @@ async function handleNode(args: string[]): Promise { await handleClearElectronDebugIdentity(subcommandArgs); break; + case 'generate-bindings': + await handleGenerateBindings(subcommandArgs); + break; + default: console.error(`Unknown node subcommand: ${subcommand}`); console.error(`Run "${CLI_NAME} node" for available subcommands.`); @@ -504,6 +559,195 @@ async function handleClearElectronDebugIdentity(args: string[]): Promise { } } +/** + * `node generate-bindings`: regenerate JS/TypeScript bindings without re-running + * the heavy native restore (no NuGet download, no cppwinrt headers, no manifest / + * cert work). Re-reads `winapp.jsBindings` from package.json and the cached + * `.winapp/winmds.lock.json` written by the last `winapp restore`, then runs + * dynwinrt-codegen. Intended for fast iteration after editing the `winapp.jsBindings` + * block (packages scope, extraTypes, skip/refOnly/emit overrides). + * + * Pre-checks fail fast with an actionable hint when a prerequisite is missing. + */ +async function handleGenerateBindings(args: string[]): Promise { + const options = parseArgs(args, { + verbose: false, + }); + + if (options.help) { + console.log(`Usage: ${CLI_NAME} node generate-bindings [options]`); + console.log(''); + console.log('Regenerate JS/TypeScript bindings from package.json + cached winmds'); + console.log(''); + console.log('This command will:'); + console.log(' 1. Read `winapp.jsBindings` from package.json'); + console.log(' 2. Read the cached winmd inventory from .winapp/winmds.lock.json'); + console.log(' 3. Run dynwinrt-codegen into the configured output directory'); + console.log(' 4. Ensure @microsoft/dynwinrt is listed in package.json dependencies'); + console.log(''); + console.log('It does NOT re-run the native restore. If you changed `winapp.yaml`'); + console.log('(packages, sdkVersion, etc.) run `winapp restore` first to refresh'); + console.log('the lockfile, then re-run this command.'); + console.log(''); + console.log('Options:'); + console.log(' --verbose Enable verbose codegen output (default: false)'); + console.log(' --help Show this help'); + console.log(''); + console.log('Examples:'); + console.log(` ${CLI_NAME} node generate-bindings`); + console.log(` ${CLI_NAME} node generate-bindings --verbose`); + return; + } + + const workspaceDir = process.cwd(); + + // 1. Must be an npm/Node project — winapp.jsBindings lives in package.json. + if (!fs.existsSync(path.join(workspaceDir, 'package.json'))) { + console.error('❌ No package.json found in this directory.'); + console.error(' This command only applies to npm/Node projects.'); + console.error(' Run `npm init -y` first, then `winapp init` to configure JS bindings.'); + process.exit(1); + } + + // 2. JS bindings must be configured. + if (!hasJsBindings(workspaceDir)) { + console.error('❌ No "winapp.jsBindings" section in package.json.'); + console.error(' Run `winapp init` to configure JS bindings.'); + process.exit(1); + } + + // 3. Lockfile from a prior `winapp restore` must be present. (Schema-mismatch + // cases get the orchestrator's more detailed message via `lockfileStale`.) + const lockfilePath = getLockfilePath(path.join(workspaceDir, '.winapp')); + if (!fs.existsSync(lockfilePath)) { + console.error(`❌ No .winapp/${LOCKFILE_NAME} found.`); + console.error(' Run `winapp restore` first to fetch SDK packages and build the winmd inventory.'); + process.exit(1); + } + + // 4. Hand off to the shared pipeline. Outcomes are translated to ✅ / ❌ /⚠️ + // by runJsBindingsOrchestrator. + await runJsBindingsOrchestrator(workspaceDir, options.verbose as boolean); +} + +/** + * `init` intercept: ask the JS bindings prompt, run native init, then (when + * the user wants JS bindings) write the `"winapp.jsBindings"` namespace to + * package.json, re-run restore so the lockfile is fresh, and orchestrate + * dynwinrt-codegen. + * + * The native CLI itself has no awareness of JS bindings — every flag, every + * code path is identical regardless of the user's choice here. + */ +async function handleInit(args: string[]): Promise { + const workspaceDir = process.cwd(); + + // Re-running init on a configured workspace? Infer the choice rather than + // re-prompt so we never silently drop the user's prior customizations. + const existingJsBindings = hasJsBindings(workspaceDir); + + let outcome; + try { + outcome = await askBindingsKind({ + workspaceDir, + argv: args, + isInit: true, + existingJsBindings, + }); + } catch (err) { + logErrorAndExit(err); + } + + if (outcome.silentReason) { + console.log(`ℹ️ ${outcome.silentReason}`); + } + + // Native init always runs with the user's literal argv (no flag injection + // and no JS-bindings-aware overrides). + await callWinappCli(['init', ...args], { exitOnError: true }); + + // User opted out — nothing more to do. + if (outcome.kind === 'no') { + return; + } + + // Persist the default jsBindings block to package.json so subsequent + // `winapp restore` runs pick it up. Skip when package.json is missing so + // we don't fail an init that already succeeded — surface a clear hint. + try { + if (!fs.existsSync(path.join(workspaceDir, 'package.json'))) { + console.warn( + '⚠️ package.json not found in this workspace. ' + + 'Run `npm init -y` (or equivalent) and then `npx winapp restore` to enable JS bindings.' + ); + return; + } + const refreshed = readJsBindingsConfig(workspaceDir); + const wantsOverwrite = outcome.overwriteExistingConfig === true; + if (!refreshed.jsBindings) { + writeJsBindingsConfig(workspaceDir, defaultJsBindingsConfig()); + console.log( + 'ℹ️ Added "winapp.jsBindings" to package.json. ' + 'Edit it to customize package scope, extraTypes, etc.' + ); + } else if (wantsOverwrite) { + writeJsBindingsConfig(workspaceDir, defaultJsBindingsConfig()); + console.log('ℹ️ Reset "winapp.jsBindings" in package.json to defaults.'); + } + } catch (err) { + console.error(`Failed to update package.json: ${(err as Error).message}`); + process.exit(1); + } + + // Trigger a restore now (writes the winmd lockfile) and run the orchestrator. + await callWinappCli(['restore'], { exitOnError: true }); + await runJsBindingsOrchestrator(workspaceDir, isVerbose(args)); +} + +/** + * `restore` intercept: run native restore unconditionally, then orchestrate + * dynwinrt-codegen iff package.json declares `winapp.jsBindings`. + */ +async function handleRestore(args: string[]): Promise { + const workspaceDir = process.cwd(); + + await callWinappCli(['restore', ...args], { exitOnError: true }); + + if (!hasJsBindings(workspaceDir)) { + return; + } + await runJsBindingsOrchestrator(workspaceDir, isVerbose(args)); +} + +/** Detect `--verbose` / `-v` (anywhere in argv) for opting into noisy codegen logs. */ +function isVerbose(args: string[]): boolean { + return args.includes('--verbose') || args.includes('-v'); +} + +/** Runs the JS bindings pipeline and translates outcomes into exit codes. */ +async function runJsBindingsOrchestrator(workspaceDir: string, verbose: boolean = false): Promise { + try { + const result = await runJsBindingsPipeline({ workspaceDir, verbose }); + switch (result.outcome) { + case 'completed': + console.log(`✅ ${result.message}`); + return; + case 'noJsBindings': + // Silent — caller already vetted that jsBindings is configured. + return; + case 'lockfileMissing': + case 'lockfileStale': + console.error(`❌ ${result.message}`); + process.exit(1); + break; + case 'noWinmdsToEmit': + console.warn(`⚠️ ${result.message}`); + return; + } + } catch (err) { + logErrorAndExit(err); + } +} + function logErrorAndExit(error: unknown): never { if (error instanceof Error && error.message.includes('winapp-cli exited with code')) { process.exit(1); diff --git a/src/winapp-npm/src/jsbindings/additional-winmds.ts b/src/winapp-npm/src/jsbindings/additional-winmds.ts new file mode 100644 index 00000000..f30eb336 --- /dev/null +++ b/src/winapp-npm/src/jsbindings/additional-winmds.ts @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. +// +// Resolves entries from `jsBindings.additionalWinmds` / `additionalRefs` to +// absolute file paths, with the same defenses as the original C# code: +// * Reject UNC / network paths before any probe (FileInfo.Exists on a UNC +// would trigger SMB negotiation and leak NTLM). +// * Reject reparse-point ancestors (symlink/junction) — for absolute paths +// under the workspace, boundary = workspace; for absolute paths outside +// the workspace, boundary = the drive root. +// * Silently skip missing files (codegen would just fail anyway). +// * Dedupe by full path, case-insensitive. + +import * as fs from 'fs'; +import * as path from 'path'; +import { isNetworkPath, hasReparsePointOnPath } from './path-safety'; + +export interface ResolveAdditionalWinmdsResult { + resolved: string[]; + warnings: string[]; +} + +export function resolveAdditionalWinmds( + entries: readonly string[] | undefined, + workspaceDir: string, + fieldName: string +): ResolveAdditionalWinmdsResult { + const resolved: string[] = []; + const warnings: string[] = []; + if (!entries || entries.length === 0) { + return { resolved, warnings }; + } + + const seen = new Set(); + const workspaceFull = path.resolve(workspaceDir).replace(/[\\/]+$/, ''); + + for (const entry of entries) { + if (typeof entry !== 'string' || !entry.trim()) { + continue; + } + const trimmed = entry.trim(); + + // Reject UNC entries up-front (before any FS probe). + if (isNetworkPath(trimmed)) { + warnings.push( + `jsBindings.${fieldName} entry refused — network/UNC paths are not allowed (would probe attacker-controlled host). Entry: ${entry}` + ); + continue; + } + + const fullPath = path.isAbsolute(trimmed) ? path.resolve(trimmed) : path.resolve(workspaceFull, trimmed); + + // Re-check after resolve: a relative path under a UNC workspace + // resolves to a UNC. + if (isNetworkPath(fullPath)) { + warnings.push( + `jsBindings.${fieldName} entry resolved to UNC path; refusing to probe. Entry: ${entry} → ${fullPath}` + ); + continue; + } + + // Reparse-point guard. + // * Relative paths and absolute paths under the workspace → boundary = workspace. + // * Absolute paths outside the workspace → boundary = drive root. + // The user explicitly opted in to an out-of-workspace path (docs + // support absolute paths); we still walk every segment for reparse + // points, but don't force workspace containment. + const sameAsWorkspace = fullPath.toLowerCase() === workspaceFull.toLowerCase(); + const underWorkspace = + sameAsWorkspace || fullPath.toLowerCase().startsWith((workspaceFull + path.sep).toLowerCase()); + const reparseBoundary = underWorkspace ? workspaceFull : path.parse(fullPath).root || workspaceFull; + + if (hasReparsePointOnPath(fullPath, reparseBoundary)) { + warnings.push( + `jsBindings.${fieldName} entry refused — file or one of its ancestors up to ${reparseBoundary} is a reparse point. Entry: ${entry} → ${fullPath}` + ); + continue; + } + + const dedupeKey = fullPath.toLowerCase(); + if (seen.has(dedupeKey)) { + continue; + } + seen.add(dedupeKey); + + if (!fs.existsSync(fullPath)) { + warnings.push(`jsBindings.${fieldName} entry not found, skipping: ${entry} (resolved to ${fullPath})`); + continue; + } + + resolved.push(fullPath); + } + + return { resolved, warnings }; +} + +// Codegen extraTypes are silently skipped when malformed; count the valid +// entries for orchestration decisions (empty-emit + no valid extra types = +// nothing to do). +export function countValidExtraTypes(extraTypes: readonly JsBindingsExtraType[] | undefined): number { + if (!extraTypes) { + return 0; + } + let count = 0; + for (const et of extraTypes) { + if (et && et.namespace && et.namespace.trim() && et.classes && et.classes.length > 0) { + count++; + } + } + return count; +} + +// Shape of one `extraTypes` entry in the JS bindings configuration block. +// The canonical schema lives in package-json-config.ts (the +// `"winapp.jsBindings"` namespace inside package.json); the type lives here +// to break a circular import between package-json-config.ts and +// additional-winmds.ts (the latter is used by codegen-runner.ts to expand +// `additionalWinmds` paths). +export interface JsBindingsExtraType { + namespace: string; + classes: string[]; +} diff --git a/src/winapp-npm/src/jsbindings/codegen-runner.ts b/src/winapp-npm/src/jsbindings/codegen-runner.ts new file mode 100644 index 00000000..0ad9ea9e --- /dev/null +++ b/src/winapp-npm/src/jsbindings/codegen-runner.ts @@ -0,0 +1,605 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. +// +// Spawns @microsoft/dynwinrt-codegen against discovered .winmd metadata, +// using a stage-then-swap pattern so a partial failure leaves the previous +// output intact. +// +// Ported from C# `DynWinrtCodegenService.cs`. Key invariants preserved: +// * Resolve output dir with strict workspace containment + reparse-point +// refusal — the directory is wiped before each run, so we must never +// follow a junction that points outside the workspace. +// * Refuse to wipe a non-empty output directory without our managed marker +// (`.dynwinrt-managed`); the user may have aimed the path at real files. +// * Stage in a sibling dir, then atomic-rename swap with backup/restore on +// failure so a kill mid-rename can't leave the user with no bindings. +// * Use ArgumentList-equivalent (spawn args array) to avoid shell quoting +// pitfalls — paths with spaces or `&` must pass through unchanged. + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import * as crypto from 'crypto'; +import { spawn } from 'child_process'; +import { JsBindingsConfig } from './package-json-config'; +import { JsBindingsExtraType } from './additional-winmds'; + +// Marker written into the output dir after a successful run; its presence +// authorises the next run to wipe the dir. +export const MANAGED_MARKER_FILE_NAME = '.dynwinrt-managed'; + +const CODEGEN_PACKAGE_NAME = '@microsoft/dynwinrt-codegen'; + +export interface CodegenInputs { + config: JsBindingsConfig; + /** Emit winmds (after winmd-policy filtering). */ + emitWinmds: readonly string[]; + /** Ref-only winmds (load for type resolution, don't generate bindings). */ + refWinmds: readonly string[]; + workspaceDir: string; + /** A logger sink for stdout/stderr lines from the codegen child. */ + log?: (line: string) => void; + /** + * When false (default), child stdout is buffered and only printed on failure; + * stderr is always forwarded. When true, stream stdout/stderr line-by-line. + */ + verbose?: boolean; +} + +export interface CodegenSummary { + classes: number; + interfaces: number; + enums: number; +} + +export interface CodegenResult { + outputDir: string; + /** Aggregated counts parsed from codegen stdout. Zeros if not detected. */ + summary: CodegenSummary; +} + +/** Top-level entry point: stage → spawn passes → swap. */ +export async function runCodegen(inputs: CodegenInputs): Promise { + const log = inputs.log ?? ((line) => process.stdout.write(line + os.EOL)); + const verbose = inputs.verbose ?? false; + + const outputDir = resolveOutputDir(inputs.workspaceDir, inputs.config.output); + fs.mkdirSync(path.dirname(outputDir), { recursive: true }); + + const emit = dedupeCaseInsensitive(inputs.emitWinmds); + // Drop refs that are already in emit (file in both wins as emit). + const refSet = new Set(emit.map((f) => f.toLowerCase())); + const refs = dedupeCaseInsensitive(inputs.refWinmds.filter((r) => !refSet.has(r.toLowerCase()))); + + const { executable, prefixArgs } = resolveCodegenInvocation(); + if (verbose) { + log(`Using codegen → ${executable} ${prefixArgs.join(' ')}`); + log(`Codegen inputs: ${emit.length} emit + ${refs.length} ref winmd(s)`); + } + + const summary: CodegenSummary = { classes: 0, interfaces: 0, enums: 0 }; + + await runWithStaging(outputDir, async (stagingDir) => { + if (emit.length > 0) { + const args = buildBulkArgs(prefixArgs, emit, stagingDir, inputs.config, refs); + const stdout = await spawnCodegen(executable, args, inputs.workspaceDir, log, verbose); + accumulateSummary(summary, parseSummary(stdout)); + } + for (const et of inputs.config.extraTypes) { + if (!et.namespace.trim() || et.classes.length === 0) { + continue; + } + const args = buildExtraTypeArgs(prefixArgs, emit, stagingDir, inputs.config, refs, et); + const stdout = await spawnCodegen(executable, args, inputs.workspaceDir, log, verbose); + accumulateSummary(summary, parseSummary(stdout)); + } + }); + + return { outputDir, summary }; +} + +// ---- output dir resolution + safety --------------------------------------- + +export function resolveOutputDir(workspaceDir: string, output: string): string { + const out = output && output.trim() ? output : 'bindings'; + const resolved = path.isAbsolute(out) ? path.resolve(out) : path.resolve(workspaceDir, out); + const workspaceFull = path.resolve(workspaceDir).replace(/[\\/]+$/, ''); + const prefix = workspaceFull + path.sep; + const inside = resolved.length > prefix.length && resolved.toLowerCase().startsWith(prefix.toLowerCase()); + if (!inside) { + throw new Error( + `jsBindings.output ('${output}') resolves to '${resolved}' which is outside the workspace ('${workspaceFull}'). ` + + 'The output directory is wiped before each codegen run, so it must be a path strictly ' + + "inside the workspace. Use a relative path like 'bindings' or an absolute path " + + 'that descends from the workspace root.' + ); + } + + // Reject reparse-point ancestors so the recursive delete can't follow a + // junction outside the workspace. + let probe = resolved; + for (;;) { + if (fs.existsSync(probe)) { + try { + const stat = fs.lstatSync(probe); + if (stat.isSymbolicLink()) { + throw new Error( + `jsBindings.output ('${output}') resolves through a reparse point at '${probe}'. ` + + 'Reparse points (symlinks / junctions) are rejected because they could redirect ' + + 'the output wipe outside the workspace. Move the output to a regular subdirectory ' + + 'of the workspace.' + ); + } + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== 'ENOENT' && code !== 'ENOTDIR') { + throw err; + } + } + } + const trimmed = probe.replace(/[\\/]+$/, ''); + if (trimmed.toLowerCase() === workspaceFull.toLowerCase()) { + break; + } + const parent = path.dirname(probe); + if (parent === probe || parent.length < workspaceFull.length) { + break; + } + probe = parent; + // Stop walking once we've reached or passed the workspace root. + if (probe.replace(/[\\/]+$/, '').toLowerCase() === workspaceFull.toLowerCase()) { + // Check the workspace itself once. + try { + if (fs.existsSync(probe) && fs.lstatSync(probe).isSymbolicLink()) { + throw new Error( + `Workspace directory '${probe}' is a reparse point. Refusing to use it as a codegen boundary.` + ); + } + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== 'ENOENT' && code !== 'ENOTDIR') { + throw err; + } + } + break; + } + } + + return resolved; +} + +/** Throws when outputDir contains files we didn't generate. Empty / missing OK. */ +export function validateOutputDirIsWipeable(outputDir: string, sep: string = path.sep): void { + if (!fs.existsSync(outputDir)) { + return; + } + + const stat = fs.lstatSync(outputDir); + if (stat.isSymbolicLink()) { + throw new Error( + `Refusing to wipe '${outputDir}': it is a reparse point (symlink or junction). ` + + 'The wipe could follow the link and delete files outside the workspace. ' + + 'Move the output to a regular directory and try again.' + ); + } + + const entries = fs.readdirSync(outputDir); + if (entries.length === 0) { + return; + } + + const marker = outputDir + sep + MANAGED_MARKER_FILE_NAME; + if (!fs.existsSync(marker)) { + throw new Error( + `Refusing to wipe non-managed output directory '${outputDir}'. ` + + `This directory contains files but does not have a '${MANAGED_MARKER_FILE_NAME}' marker, ` + + 'which indicates it was created or modified outside winapp. ' + + 'Move or delete its contents manually if you intended to reuse this path for JS bindings.' + ); + } + + for (const name of entries) { + const full = path.join(outputDir, name); + const child = fs.lstatSync(full); + if (child.isSymbolicLink()) { + throw new Error( + `Refusing to wipe '${outputDir}': child entry '${name}' is a reparse point. ` + + 'Delete it manually before re-running codegen.' + ); + } + } +} + +function writeManagedMarker(outputDir: string): void { + fs.mkdirSync(outputDir, { recursive: true }); + const markerPath = path.join(outputDir, MANAGED_MARKER_FILE_NAME); + const lines = [ + '# Generated by winapp dynwinrt-codegen integration. Do not edit.', + '# Presence of this file authorises winapp to wipe the directory on the next run.', + `generated_at: ${new Date().toISOString()}`, + '', + ]; + fs.writeFileSync(markerPath, lines.join('\n'), { encoding: 'utf8' }); +} + +// ---- staging + swap -------------------------------------------------------- + +/** Stage → backup-old → swap → drop-backup. Visible for tests. */ +export async function runWithStaging( + outputDir: string, + generate: (stagingDir: string) => Promise +): Promise { + const parent = path.dirname(outputDir); + const baseName = path.basename(outputDir); + const nonce = crypto.randomBytes(8).toString('hex'); + const stagingDir = path.join(parent, `${baseName}.staging.${nonce}`); + let backupDir: string | null = null; + let stagingActive = true; + + fs.mkdirSync(stagingDir, { recursive: true }); + try { + await generate(stagingDir); + + writeManagedMarker(stagingDir); + + validateOutputDirIsWipeable(outputDir); + + if (fs.existsSync(outputDir)) { + const backupNonce = crypto.randomBytes(8).toString('hex'); + backupDir = path.join(parent, `${baseName}.backup.${backupNonce}`); + fs.renameSync(outputDir, backupDir); + } + + try { + fs.renameSync(stagingDir, outputDir); + // Don't let the finally block target the now-renamed staging dir + // (which IS the user's new output). + stagingActive = false; + } catch (swapErr) { + // Restore previous output so the user isn't left empty. + if (backupDir !== null && fs.existsSync(backupDir)) { + try { + fs.renameSync(backupDir, outputDir); + backupDir = null; + } catch (restoreErr) { + // Preserve the backup on disk and surface the path so the user + // can recover manually. Null the local so finally won't delete it. + const preserved = backupDir; + backupDir = null; + throw new Error( + `Codegen failed AND the previous output could not be restored. ` + + `Your previous bindings are preserved at: ${preserved}. ` + + `Move them back manually if needed. Restore error: ${(restoreErr as Error).message}`, + { cause: restoreErr } + ); + } + } + throw swapErr; + } + } finally { + if (stagingActive) { + try { + fs.rmSync(stagingDir, { recursive: true, force: true }); + } catch { + /* orphan staging is harmless */ + } + } + if (backupDir !== null) { + try { + fs.rmSync(backupDir, { recursive: true, force: true }); + } catch { + /* orphan backup is harmless */ + } + } + } +} + +// ---- argv builders --------------------------------------------------------- + +export function buildBulkArgs( + prefixArgs: readonly string[], + emitWinmds: readonly string[], + outputDir: string, + config: JsBindingsConfig, + refWinmds: readonly string[] +): string[] { + const args: string[] = [ + ...prefixArgs, + 'generate', + '--winmd', + emitWinmds.join(';'), + '--output', + outputDir, + '--lang', + config.lang, + ]; + if (refWinmds.length > 0) { + args.push('--ref', refWinmds.join(';')); + } + if (config.lang === 'py') { + args.push('--pyi'); + } + return args; +} + +export function buildExtraTypeArgs( + prefixArgs: readonly string[], + emitWinmds: readonly string[], + outputDir: string, + config: JsBindingsConfig, + refWinmds: readonly string[], + extra: JsBindingsExtraType +): string[] { + const args: string[] = [...prefixArgs, 'generate']; + if (emitWinmds.length > 0) { + args.push('--winmd', emitWinmds.join(';')); + } + args.push( + '--namespace', + extra.namespace, + '--class-name', + extra.classes.join(','), + '--output', + outputDir, + '--lang', + config.lang + ); + if (refWinmds.length > 0) { + args.push('--ref', refWinmds.join(';')); + } + return args; +} + +// ---- spawn ----------------------------------------------------------------- + +async function spawnCodegen( + executable: string, + args: readonly string[], + workspaceDir: string, + log: (line: string) => void, + verbose: boolean +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(executable, args as string[], { + stdio: ['ignore', 'pipe', 'pipe'], + cwd: workspaceDir, + shell: false, + windowsHide: true, + }); + + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + child.stdout?.on('data', (c: Buffer) => stdoutChunks.push(c)); + child.stderr?.on('data', (c: Buffer) => stderrChunks.push(c)); + + child.on('error', (err) => + reject(new Error(`Failed to launch dynwinrt-codegen at '${executable}': ${err.message}`)) + ); + + child.on('close', (code) => { + const stdout = Buffer.concat(stdoutChunks).toString('utf8').trimEnd(); + const stderr = Buffer.concat(stderrChunks).toString('utf8').trimEnd(); + if (code !== 0) { + // On failure, always surface both streams so the user can diagnose. + if (stdout) { + log(stdout); + } + if (stderr) { + log(stderr); + } + reject(new Error(`dynwinrt-codegen failed (exit ${code ?? 'null'}). See output above for details.`)); + return; + } + // Success: in quiet mode, swallow both streams. dynwinrt-codegen emits + // per-file "Generated …" lines plus a "Discovered N namespace(s)" dump + // (some via stderr as progress) that drown out the orchestrator's own + // single-line success summary. Users who need the detail can pass + // `--verbose` / `-v` (handleInit / handleRestore in cli.ts). + if (verbose) { + if (stdout) { + log(stdout); + } + if (stderr) { + log(stderr); + } + } + resolve(stdout); + }); + }); +} + +// ---- summary parsing ------------------------------------------------------- + +const SUMMARY_REGEX = + /Done\.\s+(\d+)\s+class\(es\)\s+\+\s+(\d+)\s+interface\(s\)\s+\+\s+(\d+)\s+enum\(s\)\s+generated/i; + +/** Parse the trailing "Done. N class(es) + M interface(s) + K enum(s)" line. */ +export function parseSummary(stdout: string): CodegenSummary { + const summary: CodegenSummary = { classes: 0, interfaces: 0, enums: 0 }; + if (!stdout) { + return summary; + } + // Codegen may emit one summary per pass; take the last one in case of + // multi-pass output reaching this function (defensive — currently each + // spawn is its own pass). + const re = new RegExp(SUMMARY_REGEX, 'gi'); + let last: RegExpExecArray | null = null; + for (let match = re.exec(stdout); match !== null; match = re.exec(stdout)) { + last = match; + } + if (last) { + summary.classes = Number(last[1]); + summary.interfaces = Number(last[2]); + summary.enums = Number(last[3]); + } + return summary; +} + +function accumulateSummary(target: CodegenSummary, add: CodegenSummary): void { + target.classes += add.classes; + target.interfaces += add.interfaces; + target.enums += add.enums; +} + +// ---- executable resolution ------------------------------------------------- + +interface CodegenInvocation { + executable: string; + prefixArgs: string[]; +} + +// Locate dynwinrt-codegen by walking up from the wrapper install dir looking +// for node_modules/@microsoft/dynwinrt-codegen. Workspace-local installs are +// not trusted (a cloned repo could substitute a malicious codegen). +export function resolveCodegenInvocation(): CodegenInvocation { + const wrapperDir = tryGetWrapperDir(); + const arch = resolveArchSubdir(); + + let lastChecked: string | null = null; + for (let probe: string | null = wrapperDir; probe; probe = parentOrNull(probe)) { + const pkgDir = path.join(probe, 'node_modules', '@microsoft', 'dynwinrt-codegen'); + if (!fs.existsSync(pkgDir)) { + continue; + } + + // Priority 1: pre-built .exe (no Node startup needed). + const exePath = path.join(pkgDir, 'bin', arch, 'dynwinrt-codegen.exe'); + if (fs.existsSync(exePath)) { + return { executable: exePath, prefixArgs: [] }; + } + + // Priority 2: cli.js via node.exe — defensive fallback. Reject .bat/.cmd + // because they dispatch through cmd.exe and would re-parse user-derived args. + const cliJs = path.join(pkgDir, 'cli.js'); + if (fs.existsSync(cliJs)) { + const nodePath = resolveNativeNodeOnPath(); + if (!nodePath) { + throw new Error( + `The codegen at '${cliJs}' requires a native Node.js executable (node.exe) on PATH. ` + + 'Install Node 18+ (winget install OpenJS.NodeJS) ' + + `or reinstall ${CODEGEN_PACKAGE_NAME} so the pre-built .exe is available.` + ); + } + return { executable: nodePath, prefixArgs: [cliJs] }; + } + lastChecked = pkgDir; + } + + const wrapperHint = wrapperDir + ? ` at '${wrapperDir}'` + : ' (winapp install directory could not be determined; try reinstalling @microsoft/winappcli)'; + const partialHint = lastChecked + ? `Found ${CODEGEN_PACKAGE_NAME} at '${lastChecked}' but no executable inside ` + + `(expected 'bin/${arch}/dynwinrt-codegen.exe' or 'cli.js'). ` + + 'The npm package may be corrupt; reinstall it.\n\n' + : `Searched ${CODEGEN_PACKAGE_NAME} from the wrapper install${wrapperHint} — ` + + 'no node_modules/@microsoft/dynwinrt-codegen found.\n\n'; + + throw new Error( + partialHint + + 'To enable JS bindings, install via npm or yarn classic:\n' + + ' npm i -D @microsoft/winappcli\n' + + `(bundles ${CODEGEN_PACKAGE_NAME} as a transitive dependency.)\n\n` + + 'Non-hoisting layouts (pnpm default, yarn-Berry PnP) are not supported: the\n' + + 'codegen binary must live next to the winapp launcher so winapp can verify\n' + + "it ships the binary it's spawning. For pnpm, set 'node-linker=hoisted' in\n" + + ".npmrc; for yarn-Berry, set 'nodeLinker: node-modules' in .yarnrc.yml.\n\n" + + 'See https://github.com/microsoft/WinAppCli#electron--nodejs for setup details.' + ); +} + +function parentOrNull(dir: string): string | null { + const parent = path.dirname(dir); + return parent === dir ? null : parent; +} + +// Locate the winapp-npm install directory by walking from __dirname (dist/jsbindings/ +// in prod, src/jsbindings/ in test/dev) up until we find a package.json named +// @microsoft/winappcli. +function tryGetWrapperDir(): string | null { + let dir = __dirname; + const root = path.parse(dir).root; + for (;;) { + const candidate = path.join(dir, 'package.json'); + if (fs.existsSync(candidate)) { + try { + const parsed = JSON.parse(fs.readFileSync(candidate, 'utf8')) as Record; + if (parsed.name === '@microsoft/winappcli') { + return dir; + } + } catch { + /* keep walking */ + } + } + if (dir === root) { + return null; + } + const parent = path.dirname(dir); + if (parent === dir) { + return null; + } + dir = parent; + } +} + +function resolveArchSubdir(): string { + return os.arch() === 'arm64' ? 'arm64' : 'x64'; +} + +// Walk PATH looking for node.exe / node.com. Rejects relative PATH entries, +// drops CWD-equivalent entries, and only accepts native .exe/.com (no .bat/.cmd). +function resolveNativeNodeOnPath(): string | null { + const command = 'node'; + const pathEnv = process.env.PATH ?? ''; + const dirs = pathEnv.split(path.delimiter).filter((d) => d.length > 0); + const cwdFull = (() => { + try { + return path.resolve(process.cwd()); + } catch { + return null; + } + })(); + + for (const dirRaw of dirs) { + const dir = dirRaw.replace(/^"|"$/g, '').trim(); + if (!dir || dir === '.' || !path.isAbsolute(dir)) { + continue; + } + let resolvedDir: string; + try { + resolvedDir = path.resolve(dir); + } catch { + continue; + } + if (cwdFull && resolvedDir.toLowerCase() === cwdFull.toLowerCase()) { + continue; + } + for (const ext of ['.exe', '.com']) { + const candidate = path.join(resolvedDir, command + ext); + if (fs.existsSync(candidate)) { + return candidate; + } + } + const bare = path.join(resolvedDir, command); + if (fs.existsSync(bare)) { + const ext = path.extname(bare).toLowerCase(); + if (ext === '.exe' || ext === '.com') { + return bare; + } + } + } + return null; +} + +function dedupeCaseInsensitive(items: readonly string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const item of items) { + const k = item.toLowerCase(); + if (!seen.has(k)) { + seen.add(k); + out.push(item); + } + } + return out; +} diff --git a/src/winapp-npm/src/jsbindings/init-prompt.ts b/src/winapp-npm/src/jsbindings/init-prompt.ts new file mode 100644 index 00000000..ebb8d5b2 --- /dev/null +++ b/src/winapp-npm/src/jsbindings/init-prompt.ts @@ -0,0 +1,233 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. +// +// Decides whether the user wants JS/TypeScript bindings in addition to the +// C++ projections that the native CLI always sets up. +// +// Decision tree: +// * `winapp restore` (not `init`) → infer from package.json; never prompt. +// * Existing `"winapp.jsBindings"` namespace in package.json → infer Yes +// (we don't second-guess the user's prior choice). +// * .NET project (any *.csproj / *.fsproj / *.vbproj in cwd) → silent No; +// dynwinrt bindings don't target .NET (CsWinRT already provides +// projections). +// * `--use-defaults` / `-y` / `--yes` → silent Yes (npm user opted in). +// * Non-TTY stdin → silent Yes (scripted npm invocation, same default). +// * Otherwise → prompt `Add JS/TypeScript bindings? [Y/n]`. +// +// Note: `--setup-sdks none` is fast-pathed before this function is called +// (cli.ts forwards straight to the native CLI), so we never see it here. + +import * as fs from 'fs'; +import * as path from 'path'; +import * as readline from 'readline'; + +export type BindingsKind = 'yes' | 'no'; + +export interface BindingsPromptInputs { + workspaceDir: string; + /** Raw argv after the `init` command (excludes the `init` word). */ + argv: readonly string[]; + /** True for `init`. False for `restore` (which never prompts). */ + isInit: boolean; + /** True when package.json already declares `winapp.jsBindings`. */ + existingJsBindings: boolean; +} + +export interface BindingsPromptOutcome { + kind: BindingsKind; + /** Reason the prompt was skipped (silent decision). undefined when the prompt actually ran. */ + silentReason?: string; + /** + * Set when `existingJsBindings` was true at prompt time. true = user (or + * silent path) elected to overwrite the existing config with fresh defaults; + * false = preserve the user's existing config as-is. undefined when no + * existing config existed. + */ + overwriteExistingConfig?: boolean; +} + +const USE_DEFAULTS_FLAGS = new Set(['--use-defaults', '-y', '--yes']); + +export async function askBindingsKind(inputs: BindingsPromptInputs): Promise { + // Restore never re-prompts: respect whatever the workspace already declares. + if (!inputs.isInit) { + return { + kind: inputs.existingJsBindings ? 'yes' : 'no', + silentReason: inputs.existingJsBindings + ? 'inferred Yes from existing package.json winapp.jsBindings.' + : 'no existing winapp.jsBindings in package.json.', + overwriteExistingConfig: inputs.existingJsBindings ? false : undefined, + }; + } + + // Existing jsBindings + init re-run → ask whether to overwrite, mirroring + // the native CLI's `winapp.yaml exists with pinned versions. Overwrite?` and + // ` already exists. Overwrite?` prompts. Default Yes matches + // those native prompts; users can answer N to preserve customizations. + if (inputs.existingJsBindings) { + const isDotNet = detectDotNetProject(inputs.workspaceDir); + if (isDotNet) { + // Edge: someone added winapp.jsBindings to a .NET project and is now + // re-running init. Honor .NET classification and silent-preserve. + return { + kind: 'yes', + silentReason: '.NET project detected — preserving existing winapp.jsBindings without prompting.', + overwriteExistingConfig: false, + }; + } + const useDefaults = inputs.argv.some((a) => USE_DEFAULTS_FLAGS.has(a)); + if (useDefaults) { + return { + kind: 'yes', + silentReason: '--use-defaults — overwriting existing winapp.jsBindings with defaults.', + overwriteExistingConfig: true, + }; + } + if (!process.stdin.isTTY) { + // Scripted invocation — preserve existing config (safer default for + // non-interactive runs; --use-defaults is the explicit opt-in to reset). + return { + kind: 'yes', + silentReason: 'non-TTY stdin — preserving existing winapp.jsBindings.', + overwriteExistingConfig: false, + }; + } + const overwrite = await confirmationPrompt('package.json already has winapp.jsBindings. Overwrite?'); + return { kind: 'yes', overwriteExistingConfig: overwrite }; + } + + const isDotNet = detectDotNetProject(inputs.workspaceDir); + if (isDotNet) { + return { + kind: 'no', + silentReason: + '.NET project detected — JS bindings target Node/Electron via dynwinrt; .NET projects already get WinRT via CsWinRT.', + }; + } + + // JS bindings only apply to Node/Electron projects, which always have a + // package.json. Skip silently when one isn't present so we don't ask a + // question whose answer can't be honored (we'd need somewhere to write + // `winapp.jsBindings` and to inject the runtime dep). + if (!fs.existsSync(path.join(inputs.workspaceDir, 'package.json'))) { + return { + kind: 'no', + silentReason: 'no package.json in this workspace — JS bindings only apply to npm/Node projects.', + }; + } + + const useDefaults = inputs.argv.some((a) => USE_DEFAULTS_FLAGS.has(a)); + if (useDefaults) { + return { kind: 'yes', silentReason: '--use-defaults — opting in to JS bindings.' }; + } + + if (!process.stdin.isTTY) { + return { kind: 'yes', silentReason: 'non-TTY stdin — defaulting to Yes.' }; + } + + const answer = await confirmationPrompt('Add JS/TypeScript bindings to this project?'); + return { kind: answer ? 'yes' : 'no' }; +} + +function detectDotNetProject(workspaceDir: string): boolean { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(workspaceDir, { withFileTypes: true }); + } catch { + return false; + } + for (const e of entries) { + if (!e.isFile()) { + continue; + } + const ext = path.extname(e.name).toLowerCase(); + if (ext === '.csproj' || ext === '.fsproj' || ext === '.vbproj') { + return true; + } + } + return false; +} + +// Look for `--setup-sdks ` or `--setup-sdks=` in the argv. +// Mirrors the native option exactly; we don't validate the value beyond the +// "none" check (native will reject invalid values). +// +// Exported so cli.ts can fast-path `init --setup-sdks none` straight to the +// native CLI without invoking the bindings prompt (parity with the +// pre-wrapper UX where --setup-sdks none was a no-op for JS bindings). +export function parseSetupSdksArg(argv: readonly string[]): string | undefined { + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--setup-sdks' && i + 1 < argv.length) { + return argv[i + 1].trim().toLowerCase(); + } + if (a.startsWith('--setup-sdks=')) { + return a.substring('--setup-sdks='.length).trim().toLowerCase(); + } + } + return undefined; +} + +/** + * Mirrors Spectre.Console's `ConfirmationPrompt` rendering used by the native + * CLI: live prompt shows `{title} [y/n] (y):` with the hint in dim grey, + * and after the user answers the line is rewritten as `{title}: ` + * with the answer underlined. Keeps init UX consistent across native and + * npm-wrapper prompts. + */ +async function confirmationPrompt(title: string, defaultYes: boolean = true): Promise { + const useColor = !!process.stdout.isTTY && !process.env.NO_COLOR; + // Match Spectre.Console's default ConfirmationPrompt palette: + // * Choices `[y/n]` → blue (ChoicesStyle default) + // * Default value `(y)` → green (DefaultValueStyle default) + // * Post-answer value → underline (matches our C# rewrite path) + const blue = (s: string) => (useColor ? `\x1b[34m${s}\x1b[39m` : s); + const green = (s: string) => (useColor ? `\x1b[32m${s}\x1b[39m` : s); + const underline = (s: string) => (useColor ? `\x1b[4m${s}\x1b[24m` : s); + + const choices = blue('[y/n]'); + const defaultHint = green(`(${defaultYes ? 'y' : 'n'})`); + const livePrompt = `${title} ${choices} ${defaultHint}: `; + + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + try { + // Loop until we get a recognized answer (or an empty answer, which uses + // the default). Matches Spectre's behavior of refusing garbage input. + for (;;) { + const raw = await question(rl, livePrompt); + const trimmed = (raw ?? '').trim().toLowerCase(); + + let result: boolean | null = null; + if (trimmed === '') { + result = defaultYes; + } else if (trimmed === 'y' || trimmed === 'yes' || trimmed === 'true') { + result = true; + } else if (trimmed === 'n' || trimmed === 'no' || trimmed === 'false') { + result = false; + } + + if (result === null) { + // Invalid — re-prompt (Spectre prints validation error; we keep it terse). + continue; + } + + if (useColor) { + // Move cursor up one line (over the line we just wrote), clear it, + // then rewrite the prompt with the underlined answer. + process.stdout.write('\x1b[1A\x1b[2K\r'); + process.stdout.write(`${title}: ${underline(result ? 'Yes' : 'No')}\n`); + } + + return result; + } + } finally { + rl.close(); + } +} + +function question(rl: readline.Interface, prompt: string): Promise { + return new Promise((resolve) => { + rl.question(prompt, (answer) => resolve(answer)); + }); +} diff --git a/src/winapp-npm/src/jsbindings/lockfile-reader.ts b/src/winapp-npm/src/jsbindings/lockfile-reader.ts new file mode 100644 index 00000000..487b2e87 --- /dev/null +++ b/src/winapp-npm/src/jsbindings/lockfile-reader.ts @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. +// +// Reads `.winapp/winmds.lock.json` written by the native CLI's `restore`. +// The lockfile is a pure NuGet winmd inventory keyed by package id; the +// emit/refOnly/skip classification lives in `winmd-policy.ts` and is +// applied at codegen time, not at lockfile time. +// +// Ported from C# `WinmdsLockfileService.TryReadAsync`. Schema version +// mismatches return null with a console hint so the caller can ask the +// user to re-run `winapp restore`. + +import * as fs from 'fs'; +import * as path from 'path'; + +// Schema bumped to 3 when the npm wrapper took over JS bindings: schema 2 +// embedded a `category` field that is now strictly an npm-side computation. +export const LOCKFILE_SCHEMA_VERSION = 3; +export const LOCKFILE_NAME = 'winmds.lock.json'; + +export interface WinmdsLockfilePackage { + name: string; + version: string; + winmds: string[]; +} + +export interface WinmdsLockfile { + schemaVersion: number; + generatedAt?: string; + nugetCacheDir?: string; + yamlPackagesHash?: string; + packages: WinmdsLockfilePackage[]; +} + +export function getLockfilePath(winappDir: string): string { + return path.join(winappDir, LOCKFILE_NAME); +} + +export interface ReadLockfileResult { + lockfile: WinmdsLockfile | null; + /** Human-readable reason when lockfile is null but a file existed. */ + reason?: string; +} + +// Reads + parses the lockfile, validating the schema version. Returns null +// when the file is missing, unreadable, malformed, or schema-mismatched — +// callers should treat any null as "trigger live discovery / ask the user +// to rerun restore". +export function tryReadLockfile(winappDir: string): ReadLockfileResult { + const filePath = getLockfilePath(winappDir); + if (!fs.existsSync(filePath)) { + return { lockfile: null }; + } + + let raw: string; + try { + raw = fs.readFileSync(filePath, 'utf8'); + } catch (err) { + return { + lockfile: null, + reason: `Failed to read ${filePath}: ${(err as Error).message}`, + }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + return { + lockfile: null, + reason: `Lockfile ${filePath} is not valid JSON: ${(err as Error).message}`, + }; + } + + if (!parsed || typeof parsed !== 'object') { + return { + lockfile: null, + reason: `Lockfile ${filePath} root is not an object`, + }; + } + + const obj = parsed as Record; + // Native writes `schemaVersion`; tolerate the legacy `schema` key just in case. + const schemaRaw = obj.schemaVersion ?? obj.schema; + const schemaVersion = + typeof schemaRaw === 'number' ? schemaRaw : typeof schemaRaw === 'string' ? Number(schemaRaw) : Number.NaN; + + if (!Number.isFinite(schemaVersion) || schemaVersion !== LOCKFILE_SCHEMA_VERSION) { + return { + lockfile: null, + reason: + `Lockfile ${filePath} schema mismatch (got ${schemaRaw}, expected ${LOCKFILE_SCHEMA_VERSION}). ` + + `Re-run \`winapp restore\` to regenerate.`, + }; + } + + const packagesRaw = obj.packages; + if (!Array.isArray(packagesRaw)) { + return { + lockfile: null, + reason: `Lockfile ${filePath} has no 'packages' array`, + }; + } + + const packages: WinmdsLockfilePackage[] = []; + for (const entry of packagesRaw) { + if (!entry || typeof entry !== 'object') { + continue; + } + const e = entry as Record; + const name = typeof e.name === 'string' ? e.name : null; + const version = typeof e.version === 'string' ? e.version : null; + if (!name || !version) { + continue; + } + const winmdsArr = Array.isArray(e.winmds) ? e.winmds.filter((w): w is string => typeof w === 'string') : []; + packages.push({ name, version, winmds: winmdsArr }); + } + + const lockfile: WinmdsLockfile = { + schemaVersion, + generatedAt: typeof obj.generatedAt === 'string' ? obj.generatedAt : undefined, + nugetCacheDir: typeof obj.nugetCacheDir === 'string' ? obj.nugetCacheDir : undefined, + yamlPackagesHash: typeof obj.yamlPackagesHash === 'string' ? obj.yamlPackagesHash : undefined, + packages, + }; + return { lockfile }; +} diff --git a/src/winapp-npm/src/jsbindings/orchestrator.ts b/src/winapp-npm/src/jsbindings/orchestrator.ts new file mode 100644 index 00000000..8ac51f6c --- /dev/null +++ b/src/winapp-npm/src/jsbindings/orchestrator.ts @@ -0,0 +1,208 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. +// +// Top-level glue for JS bindings generation. Called after native `winapp restore` +// has written the winmd lockfile and after we've established that the user +// wants JS bindings (`winapp.jsBindings` namespace present in package.json). +// +// Pipeline: +// 1. Read package.json → get jsBindings config (npm wrapper-owned). +// 2. Read .winapp/winmds.lock.json → get NuGet winmd inventory. +// 3. Resolve user-supplied additional winmds + refs (reparse / UNC safety). +// 4. Partition by package category (skip / refOnly / emit) with user overrides. +// 5. Run dynwinrt-codegen (bulk + per-extraType passes) into staged dir. +// 6. Ensure @microsoft/dynwinrt is in package.json dependencies + print PM hint. +// +// Returns a structured outcome (not exceptions for "no jsBindings configured") +// so the cli.ts caller can decide whether to print anything. + +import * as path from 'path'; +import { readJsBindingsConfig, JsBindingsConfig } from './package-json-config'; +import { tryReadLockfile } from './lockfile-reader'; +import { partitionByPackageCategory } from './winmd-policy'; +import { resolveAdditionalWinmds } from './additional-winmds'; +import { runCodegen } from './codegen-runner'; +import { ensureRuntimeDependency, formatRuntimeDependencyHint, getDynWinrtVersionPin } from './runtime-dep-injector'; +import { detectPackageManager } from './package-manager-detector'; +import { startSpinner, Spinner } from './spinner'; + +export const RUNTIME_PACKAGE_NAME = '@microsoft/dynwinrt'; + +export type OrchestratorOutcome = 'noJsBindings' | 'completed' | 'lockfileMissing' | 'lockfileStale' | 'noWinmdsToEmit'; + +export interface OrchestratorResult { + outcome: OrchestratorOutcome; + /** Human-readable diagnostic. Always set. */ + message: string; + /** Output dir written by codegen (only when outcome === 'completed'). */ + outputDir?: string; +} + +export interface OrchestratorOptions { + workspaceDir: string; + /** Override for the npm wrapper's pinned dynwinrt version (used in tests). */ + versionOverride?: string; + /** Sink for per-line progress (stdout/stderr from codegen). Defaults to console. */ + log?: (line: string) => void; + /** Forward to codegen-runner. False (default) suppresses per-file noise. */ + verbose?: boolean; +} + +export async function runJsBindingsPipeline(options: OrchestratorOptions): Promise { + const log = options.log ?? ((line) => console.log(line)); + const workspaceDir = path.resolve(options.workspaceDir); + + // 1. Read package.json for the `winapp.jsBindings` namespace. + const pkgResult = readJsBindingsConfig(workspaceDir); + if (!pkgResult.packageJsonExists) { + return { + outcome: 'noJsBindings', + message: `No package.json found in ${workspaceDir} — skipping JS bindings.`, + }; + } + if (!pkgResult.jsBindings) { + return { + outcome: 'noJsBindings', + message: 'No "winapp.jsBindings" namespace in package.json — skipping JS bindings.', + }; + } + const config = pkgResult.jsBindings; + + // 2. Read lockfile. + const winappDir = path.join(workspaceDir, '.winapp'); + const lockResult = tryReadLockfile(winappDir); + if (!lockResult.lockfile) { + return { + outcome: lockResult.reason?.includes('schema mismatch') ? 'lockfileStale' : 'lockfileMissing', + message: + lockResult.reason ?? `No ${path.join(winappDir, 'winmds.lock.json')} found. Run \`winapp restore\` first.`, + }; + } + const lockfile = lockResult.lockfile; + + // 3. Resolve user-supplied additional winmds (each independently). + const userEmit = resolveAdditionalWinmds(config.additionalWinmds, workspaceDir, 'additionalWinmds'); + const userRefs = resolveAdditionalWinmds(config.additionalRefs, workspaceDir, 'additionalRefs'); + for (const w of [...userEmit.warnings, ...userRefs.warnings]) { + log(w); + } + + // 4. Partition NuGet winmds by category. Per-package overrides from config. + const flatWinmds: string[] = []; + for (const pkg of lockfile.packages) { + for (const w of pkg.winmds) { + flatWinmds.push(w); + } + } + const partition = partitionByPackageCategory(flatWinmds, { + overrides: { + skip: config.skipPackages, + refOnly: config.refOnlyPackages, + emit: config.emitPackages, + }, + nugetCacheRoot: lockfile.nugetCacheDir, + emitScope: config.packages.length > 0 ? config.packages : undefined, + }); + + // 5. Compose final emit + ref sets. + const emitWinmds = [...partition.emit, ...userEmit.resolved]; + const refWinmds = [...partition.refOnly, ...userRefs.resolved]; + + if (emitWinmds.length === 0 && countValidExtraTypes(config) === 0) { + return { + outcome: 'noWinmdsToEmit', + message: + 'No winmds matched the emit policy and no extraTypes are configured — nothing to generate. ' + + 'Add packages: entries (or wider scope) or extraTypes: in package.json `winapp.jsBindings`.', + }; + } + + // 6. Run codegen. Show a TTY spinner so the user sees progress during the + // ~30s where codegen-runner suppresses all child output (quiet mode). + // Spinner is suppressed in verbose mode (where codegen prints its own + // line-by-line output) and when the caller injected a custom log sink + // (e.g., tests — we mustn't interleave ANSI noise with assertion output). + const progressText = + `Generating JS bindings from ${emitWinmds.length} winmd${emitWinmds.length === 1 ? '' : 's'}` + + (refWinmds.length > 0 ? ` (+${refWinmds.length} ref)` : '') + + `...`; + const useSpinner = !options.log && !options.verbose; + let spinner: Spinner | null = null; + if (useSpinner) { + spinner = startSpinner(progressText); + } else { + log(`🔨 ${progressText}`); + } + + let codegenResult; + try { + codegenResult = await runCodegen({ + config, + emitWinmds, + refWinmds, + workspaceDir, + log, + verbose: options.verbose, + }); + } finally { + spinner?.stop(); + } + + // 7. Ensure runtime dep + print PM hint. + const pinnedVersion = options.versionOverride ?? safeGetVersionPin(log); + if (pinnedVersion) { + try { + const ensureResult = ensureRuntimeDependency(workspaceDir, RUNTIME_PACKAGE_NAME, pinnedVersion); + const pm = detectPackageManager(workspaceDir); + const hint = formatRuntimeDependencyHint( + ensureResult.outcome, + RUNTIME_PACKAGE_NAME, + ensureResult.pinnedVersion, + pm.installCommand + ); + log(hint.message); + } catch (err) { + log(`⚠️ Failed to ensure runtime dependency: ${(err as Error).message}`); + } + } + + return { + outcome: 'completed', + message: formatCompletedMessage(codegenResult.outputDir, codegenResult.summary), + outputDir: codegenResult.outputDir, + }; +} + +function formatCompletedMessage( + outputDir: string, + summary: { classes: number; interfaces: number; enums: number } +): string { + const hasCounts = summary.classes > 0 || summary.interfaces > 0 || summary.enums > 0; + if (!hasCounts) { + return `Generated JS bindings → ${outputDir}`; + } + const parts: string[] = []; + if (summary.classes > 0) parts.push(`${summary.classes} class${summary.classes === 1 ? '' : 'es'}`); + if (summary.interfaces > 0) parts.push(`${summary.interfaces} interface${summary.interfaces === 1 ? '' : 's'}`); + if (summary.enums > 0) parts.push(`${summary.enums} enum${summary.enums === 1 ? '' : 's'}`); + return `Generated JS bindings → ${outputDir} (${parts.join(', ')})`; +} + +function countValidExtraTypes(config: JsBindingsConfig): number { + let count = 0; + for (const et of config.extraTypes) { + if (et.namespace && et.namespace.trim() && et.classes && et.classes.length > 0) { + count++; + } + } + return count; +} + +function safeGetVersionPin(log: (line: string) => void): string | null { + try { + return getDynWinrtVersionPin(); + } catch (err) { + log(`⚠️ Could not resolve pinned ${RUNTIME_PACKAGE_NAME} version: ${(err as Error).message}`); + return null; + } +} diff --git a/src/winapp-npm/src/jsbindings/package-json-config.ts b/src/winapp-npm/src/jsbindings/package-json-config.ts new file mode 100644 index 00000000..1e83b70b --- /dev/null +++ b/src/winapp-npm/src/jsbindings/package-json-config.ts @@ -0,0 +1,275 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. +// +// Reads and writes the `"winapp": { "jsBindings": {...} }` namespace inside +// the workspace's package.json. +// +// Why package.json instead of winapp.yaml? +// * `winapp.yaml` is owned by the native CLI and only describes SDK +// `packages:` pins. Layering JS-only configuration in there meant the +// native CLI had to either parse and ignore a JS-only block or risk +// mangling unknown keys on round-trip. +// * package.json already exists in every npm/Node workspace and is the +// canonical place for Node-tool configuration (eslint, jest, prettier, +// tsup, ...). The `"winapp"` key follows the same convention. +// * The native CLI now has zero awareness of JS bindings — every code path +// (init, restore, package, ...) is identical regardless of whether the +// user opted into JS bindings. + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +import { JsBindingsExtraType } from './additional-winmds'; + +export interface JsBindingsConfig { + // Target language. Currently 'js' (default) or 'py'. + lang: string; + // Output directory, relative to the workspace root. + output: string; + // NuGet package IDs to scope binding generation to (empty = all in scope). + packages: string[]; + // Individual classes to generate alongside the bulk pass. + extraTypes: JsBindingsExtraType[]; + // Extra .winmd files to emit bindings for. + additionalWinmds: string[]; + // Extra .winmd files loaded for type resolution only. + additionalRefs: string[]; + // NuGet package IDs to drop entirely. + skipPackages: string[]; + // NuGet package IDs to load as --ref only. + refOnlyPackages: string[]; + // NuGet package IDs to force-emit, overriding skip / ref-only. + emitPackages: string[]; +} + +export function defaultJsBindingsConfig(): JsBindingsConfig { + return { + lang: 'js', + output: 'bindings', + packages: [], + extraTypes: [], + additionalWinmds: [], + additionalRefs: [], + skipPackages: [], + refOnlyPackages: [], + emitPackages: [], + }; +} + +export interface ReadJsBindingsResult { + /** True when package.json existed and parsed successfully. */ + packageJsonExists: boolean; + /** Parsed jsBindings config, or null when the namespace isn't present. */ + jsBindings: JsBindingsConfig | null; +} + +const PACKAGE_JSON = 'package.json'; + +/** + * Read package.json from the workspace and return any + * `"winapp": { "jsBindings": {...} }` namespace it declares. + * + * Missing file → `{ packageJsonExists: false, jsBindings: null }`. + * Present file, no `winapp.jsBindings` → `{ packageJsonExists: true, jsBindings: null }`. + * Malformed JSON propagates as an exception so callers can surface a clear + * error rather than silently treating the workspace as un-configured. + */ +export function readJsBindingsConfig(workspaceDir: string): ReadJsBindingsResult { + const filePath = path.join(workspaceDir, PACKAGE_JSON); + if (!fs.existsSync(filePath)) { + return { packageJsonExists: false, jsBindings: null }; + } + const raw = fs.readFileSync(filePath, 'utf8'); + const parsed = JSON.parse(raw); + const ns = parsed && typeof parsed === 'object' ? parsed.winapp : undefined; + const block = ns && typeof ns === 'object' ? ns.jsBindings : undefined; + if (!block || typeof block !== 'object') { + return { packageJsonExists: true, jsBindings: null }; + } + return { packageJsonExists: true, jsBindings: coerceConfig(block) }; +} + +/** Convenience: returns true when package.json declares `winapp.jsBindings`. */ +export function hasJsBindings(workspaceDir: string): boolean { + try { + return readJsBindingsConfig(workspaceDir).jsBindings !== null; + } catch { + return false; + } +} + +/** + * Write (or update) the `"winapp": { "jsBindings": {...} }` namespace in + * package.json. + * + * Behaviour: + * * Preserves the existing 2-space indent + trailing newline. We do not + * pull in `prettier` for this single edit — JSON.stringify gives us a + * stable canonical layout and `package.json` is the only file we own. + * * Atomic: writes to a sibling temp file, fsyncs, then renames over the + * real file so a half-written package.json is never visible. + * * Inserts the `"winapp"` key at the end of the top-level object when it + * does not yet exist — npm tooling does not care about key order, and + * stable insertion keeps round-trips clean. + * * Throws when package.json is missing or malformed; callers should + * ensure the file exists (e.g. by suggesting `npm init -y`) before + * writing. + */ +export function writeJsBindingsConfig(workspaceDir: string, config: JsBindingsConfig): void { + const filePath = path.join(workspaceDir, PACKAGE_JSON); + if (!fs.existsSync(filePath)) { + throw new Error( + `package.json not found in ${workspaceDir}. ` + + 'Run `npm init -y` (or equivalent) before adding JS bindings configuration.' + ); + } + const raw = fs.readFileSync(filePath, 'utf8'); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`Unexpected JSON shape in ${filePath}: top-level value must be an object.`); + } + + const existingNs = parsed.winapp && typeof parsed.winapp === 'object' ? parsed.winapp : {}; + parsed.winapp = { ...existingNs, jsBindings: serializeConfig(config) }; + + const eol = detectEol(raw); + const trailing = raw.endsWith('\n') ? eol : ''; + const serialized = JSON.stringify(parsed, null, 2).replace(/\n/g, eol) + trailing; + + atomicWriteFileSync(filePath, serialized); +} + +/** + * Hook for tests / future helpers: render the config block as it would be + * embedded in package.json. Returns the JSON-serializable shape — callers + * typically don't need this directly, but the orchestrator tests use it to + * assert round-trip behaviour without re-implementing the schema. + */ +export function renderJsBindingsConfig(config: JsBindingsConfig): unknown { + return serializeConfig(config); +} + +// --------------------------------------------------------------------------- +// Internals +// --------------------------------------------------------------------------- + +function coerceConfig(raw: unknown): JsBindingsConfig { + const defaults = defaultJsBindingsConfig(); + if (!raw || typeof raw !== 'object') { + return defaults; + } + const r = raw as Record; + + return { + lang: typeof r.lang === 'string' && r.lang.trim() ? r.lang.trim() : defaults.lang, + output: typeof r.output === 'string' && r.output.trim() ? r.output.trim() : defaults.output, + packages: coerceStringArray(r.packages), + extraTypes: coerceExtraTypes(r.extraTypes), + additionalWinmds: coerceStringArray(r.additionalWinmds), + additionalRefs: coerceStringArray(r.additionalRefs), + skipPackages: coerceStringArray(r.skipPackages), + refOnlyPackages: coerceStringArray(r.refOnlyPackages), + emitPackages: coerceStringArray(r.emitPackages), + }; +} + +function coerceStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + const out: string[] = []; + for (const v of value) { + if (typeof v === 'string') { + const trimmed = v.trim(); + if (trimmed) { + out.push(trimmed); + } + } + } + return out; +} + +function coerceExtraTypes(value: unknown): JsBindingsExtraType[] { + if (!Array.isArray(value)) { + return []; + } + const out: JsBindingsExtraType[] = []; + for (const v of value) { + if (!v || typeof v !== 'object') { + continue; + } + const r = v as Record; + const ns = typeof r.namespace === 'string' ? r.namespace.trim() : ''; + const classes = coerceStringArray(r.classes); + if (ns) { + out.push({ namespace: ns, classes }); + } + } + return out; +} + +/** + * Serialize a JsBindingsConfig in a stable, schema-faithful shape: + * * keys are emitted in a fixed order so diffs stay clean across edits; + * * empty arrays are kept (they're documentation: "yes I considered this, + * and meant the empty default") rather than stripped. + */ +function serializeConfig(config: JsBindingsConfig): Record { + return { + lang: config.lang, + output: config.output, + packages: [...config.packages], + extraTypes: config.extraTypes.map((et) => ({ + namespace: et.namespace, + classes: [...et.classes], + })), + additionalWinmds: [...config.additionalWinmds], + additionalRefs: [...config.additionalRefs], + skipPackages: [...config.skipPackages], + refOnlyPackages: [...config.refOnlyPackages], + emitPackages: [...config.emitPackages], + }; +} + +function detectEol(content: string): string { + // Match the file's predominant line ending so we don't accidentally + // rewrite CRLF → LF (or vice versa) on Windows checkouts. + return content.includes('\r\n') ? '\r\n' : '\n'; +} + +function atomicWriteFileSync(filePath: string, content: string): void { + const dir = path.dirname(filePath); + const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`); + let cleanup = true; + try { + const fd = fs.openSync(tmp, 'w'); + try { + fs.writeFileSync(fd, content); + try { + fs.fsyncSync(fd); + } catch { + // fsync isn't supported on every platform (e.g. some FUSE mounts on + // CI); the rename itself is enough for atomicity on POSIX and NTFS. + } + } finally { + fs.closeSync(fd); + } + fs.renameSync(tmp, filePath); + cleanup = false; + } finally { + if (cleanup) { + try { + fs.unlinkSync(tmp); + } catch { + // best-effort temp cleanup + } + } + } +} + +// Re-exported so callers don't have to know whether the implementation lives +// in this module or elsewhere. +export const PACKAGE_JSON_FILENAME = PACKAGE_JSON; +// Hint: os.EOL is intentionally unused — we prefer the file's existing EOL. +void os; diff --git a/src/winapp-npm/src/jsbindings/package-manager-detector.ts b/src/winapp-npm/src/jsbindings/package-manager-detector.ts new file mode 100644 index 00000000..6c234326 --- /dev/null +++ b/src/winapp-npm/src/jsbindings/package-manager-detector.ts @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. +// +// Detects which package manager (npm / yarn / pnpm / bun) a workspace uses, +// so we can print the right install command after mutating package.json. +// +// Ported from C# `PackageManagerDetector.cs`. Priority: +// 1. Corepack `packageManager` field in package.json +// 2. Lockfile sniffing (pnpm-lock.yaml → pnpm, yarn.lock → yarn, etc.) +// 3. Fallback: npm +// +// Pure synchronous filesystem reads; no spawn. + +import * as fs from 'fs'; +import * as path from 'path'; + +export interface DetectedPackageManager { + name: 'npm' | 'yarn' | 'pnpm' | 'bun'; + installCommand: string; +} + +export function detectPackageManager(workspaceDir: string): DetectedPackageManager { + // Priority 1: Corepack packageManager field. + const pkgJson = path.join(workspaceDir, 'package.json'); + if (fs.existsSync(pkgJson)) { + const fromCorepack = tryReadCorepackField(pkgJson); + if (fromCorepack) { + return fromCorepack; + } + } + + // Priority 2: lockfile sniffing. pnpm/yarn/bun first because + // package-lock.json is sometimes auto-created by tools in non-npm workspaces. + if (fs.existsSync(path.join(workspaceDir, 'pnpm-lock.yaml'))) { + return { name: 'pnpm', installCommand: 'pnpm install' }; + } + if (fs.existsSync(path.join(workspaceDir, 'yarn.lock'))) { + return { name: 'yarn', installCommand: 'yarn install' }; + } + if (fs.existsSync(path.join(workspaceDir, 'bun.lockb')) || fs.existsSync(path.join(workspaceDir, 'bun.lock'))) { + return { name: 'bun', installCommand: 'bun install' }; + } + if ( + fs.existsSync(path.join(workspaceDir, 'package-lock.json')) || + fs.existsSync(path.join(workspaceDir, 'npm-shrinkwrap.json')) + ) { + return { name: 'npm', installCommand: 'npm install' }; + } + + // Fallback. + return { name: 'npm', installCommand: 'npm install' }; +} + +function tryReadCorepackField(packageJsonPath: string): DetectedPackageManager | null { + let raw: string; + try { + raw = fs.readFileSync(packageJsonPath, 'utf8'); + } catch { + return null; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + + if (!parsed || typeof parsed !== 'object') { + return null; + } + const pm = (parsed as Record).packageManager; + if (typeof pm !== 'string' || !pm.trim()) { + return null; + } + + // Format: "@" with optional "+sha" suffix. + const atIndex = pm.indexOf('@'); + const name = atIndex >= 0 ? pm.substring(0, atIndex) : pm; + switch (name.trim().toLowerCase()) { + case 'npm': + return { name: 'npm', installCommand: 'npm install' }; + case 'yarn': + return { name: 'yarn', installCommand: 'yarn install' }; + case 'pnpm': + return { name: 'pnpm', installCommand: 'pnpm install' }; + case 'bun': + return { name: 'bun', installCommand: 'bun install' }; + default: + // Unknown PM declaration; fall through to lockfile sniffing. + return null; + } +} diff --git a/src/winapp-npm/src/jsbindings/path-safety.ts b/src/winapp-npm/src/jsbindings/path-safety.ts new file mode 100644 index 00000000..8a082647 --- /dev/null +++ b/src/winapp-npm/src/jsbindings/path-safety.ts @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. +// +// Filesystem-safety helpers ported from the C# `PathSafety` helper. Used by +// every site that writes into the user's workspace (package.json, winapp.yaml, +// codegen output) and by additional-winmds resolution. +// +// Invariants matching the C# original: +// * `isNetworkPath` rejects UNC / `\\?\UNC\…` / `\\.\UNC\…`. Local DOS device +// paths (`\\?\C:\…`) are NOT network. +// * `hasReparsePointOnPath` walks DOWN from `boundary` to `path`. Walking up +// would force the OS to traverse junctions/symlinks in `path` to read the +// leaf's attributes, which on Windows would trigger SMB negotiation / +// NTLM leak before we ever saw the reparse-point flag. + +import * as fs from 'fs'; +import * as path from 'path'; + +// True for UNC / network paths (`\\server\share`, `\\?\UNC\…`, `\\.\UNC\…`). +// Local DOS device paths (`\\?\C:\…`, `\\.\C:\…`) are not network. +export function isNetworkPath(p: string): boolean { + if (!p) { + return false; + } + // Normalize forward slashes — Windows accepts both. + const norm = p.replace(/\//g, '\\'); + + if (!norm.startsWith('\\\\')) { + return false; + } + + // Plain UNC: `\\server\share\…` + if (!norm.startsWith('\\\\?\\') && !norm.startsWith('\\\\.\\')) { + return true; + } + + // DOS device namespace: `\\?\UNC\…` or `\\.\UNC\…` is network; other + // device paths (drive letters) are not. + const afterPrefix = norm.substring(4); + return /^UNC\\/i.test(afterPrefix); +} + +// True if `targetPath` is not safely contained under `boundary`, or if any +// segment from `boundary` down to `targetPath` is a reparse point, or if +// either side is a UNC path. Used to refuse rewriting files that a hostile +// workspace could redirect via a symlink/junction to a victim location. +export function hasReparsePointOnPath(targetPath: string, boundary: string): boolean { + if (!targetPath || !boundary) { + return false; + } + if (isNetworkPath(targetPath) || isNetworkPath(boundary)) { + return true; + } + + // Resolve both paths to absolute, normalized form for containment check. + let absTarget: string; + let absBoundary: string; + try { + absTarget = path.resolve(targetPath); + absBoundary = path.resolve(boundary).replace(/[\\/]+$/, ''); + } catch { + // If we can't even resolve the paths, treat as unsafe — safer default. + return true; + } + + const sep = path.sep; + const boundaryWithSep = absBoundary + sep; + + // Containment: target must equal boundary OR be a descendant. + const sameAsBoundary = absTarget.toLowerCase() === absBoundary.toLowerCase(); + const insideBoundary = absTarget.toLowerCase().startsWith(boundaryWithSep.toLowerCase()); + if (!sameAsBoundary && !insideBoundary) { + return true; + } + + // Walk DOWN from boundary to target, checking each existing segment's + // attributes via lstat (does NOT follow symlinks). + const rel = sameAsBoundary ? '' : absTarget.substring(boundaryWithSep.length); + const segments = rel.length === 0 ? [] : rel.split(/[\\/]/).filter((s) => s.length > 0); + + let probe = absBoundary; + if (isReparseSegment(probe)) { + return true; + } + for (const seg of segments) { + probe = path.join(probe, seg); + if (isReparseSegment(probe)) { + return true; + } + } + return false; +} + +function isReparseSegment(p: string): boolean { + let stat: fs.Stats; + try { + stat = fs.lstatSync(p); + } catch (err) { + // Missing leaf is fine — caller will create it. Permission denied / + // other unexpected error: treat as safe so we don't refuse the whole + // workspace; the subsequent write will surface the real error. + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'ENOTDIR') { + return false; + } + return false; + } + return stat.isSymbolicLink(); +} diff --git a/src/winapp-npm/src/jsbindings/runtime-dep-injector.ts b/src/winapp-npm/src/jsbindings/runtime-dep-injector.ts new file mode 100644 index 00000000..9db0ff26 --- /dev/null +++ b/src/winapp-npm/src/jsbindings/runtime-dep-injector.ts @@ -0,0 +1,262 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. +// +// Mutates the user's package.json to declare `@microsoft/dynwinrt` as a +// production dependency. Returns a structured outcome so the caller can +// print the right hint. +// +// Ported from C# `UserPackageJsonService.cs`. Key invariants: +// * Refuse to write through reparse-point ancestors (symlinks / junctions) +// — same protection the native side enforced via PathSafety. +// * Preserve unrelated keys exactly. Insert "dependencies" right after +// "version" when creating the block from scratch. +// * Atomic write via sibling tmp + fs.renameSync (Windows: same-volume rename +// is atomic; fall back to copy+unlink if the rename fails). +// * Don't auto-promote a dev→prod dep — the user pinned it under dev for a +// reason; report `PresentInDevDependencies` instead. + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { hasReparsePointOnPath } from './path-safety'; + +export type RuntimeDependencyOutcome = 'added' | 'alreadyPresent' | 'presentInDevDependencies' | 'noPackageJson'; + +export interface EnsureRuntimeDependencyResult { + outcome: RuntimeDependencyOutcome; + /** When `outcome === 'added'`, the value that was written. */ + pinnedVersion?: string; +} + +export function ensureRuntimeDependency( + workspaceDir: string, + packageName: string, + version: string +): EnsureRuntimeDependencyResult { + if (!packageName.trim()) { + throw new Error('packageName must not be empty'); + } + if (!version.trim()) { + throw new Error('version must not be empty'); + } + + const packageJsonPath = path.join(workspaceDir, 'package.json'); + + // Refuse to follow reparse points / UNC ancestors BEFORE probing existence. + if (hasReparsePointOnPath(packageJsonPath, workspaceDir)) { + throw new Error( + `Refusing to rewrite '${packageJsonPath}': the file or one of its ` + + 'ancestors is a symbolic link / reparse point. Resolve the link ' + + 'and re-run, or add the runtime dependency manually.' + ); + } + + if (!fs.existsSync(packageJsonPath)) { + return { outcome: 'noPackageJson' }; + } + + let original: string; + try { + original = fs.readFileSync(packageJsonPath, 'utf8'); + } catch (err) { + throw new Error(`Failed to read ${packageJsonPath}: ${(err as Error).message}`, { cause: err }); + } + + let root: unknown; + try { + root = JSON.parse(original); + } catch (err) { + throw new Error(`Failed to parse ${packageJsonPath}: ${(err as Error).message}`, { cause: err }); + } + + if (!root || typeof root !== 'object' || Array.isArray(root)) { + throw new Error(`${packageJsonPath} root is not a JSON object.`); + } + + const obj = root as Record; + const deps = obj.dependencies; + if (deps && typeof deps === 'object' && !Array.isArray(deps)) { + if (packageName in (deps as Record)) { + return { outcome: 'alreadyPresent' }; + } + } + + const devDeps = obj.devDependencies; + if (devDeps && typeof devDeps === 'object' && !Array.isArray(devDeps)) { + if (packageName in (devDeps as Record)) { + return { outcome: 'presentInDevDependencies' }; + } + } + + // Add to dependencies; insert the block right after "version" when creating it. + const rebuilt = insertOrUpdateDependency(obj, packageName, version); + + // npm/yarn/pnpm conventionally use 2-space indent + trailing newline. + const serialized = JSON.stringify(rebuilt, null, 2); + const final = original.endsWith('\n') && !serialized.endsWith('\n') ? serialized + '\n' : serialized; + + atomicWriteFile(packageJsonPath, final); + return { outcome: 'added', pinnedVersion: version }; +} + +function insertOrUpdateDependency( + obj: Record, + packageName: string, + version: string +): Record { + const existingDeps = obj.dependencies; + if (existingDeps && typeof existingDeps === 'object' && !Array.isArray(existingDeps)) { + const deps = { ...(existingDeps as Record) }; + deps[packageName] = version; + return { ...obj, dependencies: deps }; + } + + // No dependencies block — rebuild the object inserting "dependencies" + // right after "version" (conventional layout). + const newDeps: Record = { [packageName]: version }; + const rebuilt: Record = {}; + let inserted = false; + for (const [key, value] of Object.entries(obj)) { + rebuilt[key] = value; + if (!inserted && key === 'version') { + rebuilt.dependencies = newDeps; + inserted = true; + } + } + if (!inserted) { + rebuilt.dependencies = newDeps; + } + return rebuilt; +} + +function atomicWriteFile(filePath: string, content: string): void { + const dir = path.dirname(filePath); + const tmpName = `${path.basename(filePath)}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}`; + const tmpPath = path.join(dir, tmpName); + let staged = false; + try { + fs.writeFileSync(tmpPath, content, { encoding: 'utf8' }); + staged = true; + // Windows: same-volume rename is atomic. fs.renameSync overwrites the target. + fs.renameSync(tmpPath, filePath); + staged = false; + } catch (err) { + // Fallback for the rare cross-volume case (or AV / sharing-violation + // races): copy+unlink. Not atomic, but better than leaving the file + // mid-write. + if (staged) { + try { + fs.copyFileSync(tmpPath, filePath); + try { + fs.unlinkSync(tmpPath); + } catch { + /* leaked tmp is harmless */ + } + return; + } catch (fallbackErr) { + try { + fs.unlinkSync(tmpPath); + } catch { + /* ignore */ + } + throw new Error( + `Failed to write ${filePath}: ${(fallbackErr as Error).message} (after rename error: ${(err as Error).message})`, + { cause: fallbackErr } + ); + } + } + throw new Error(`Failed to write ${filePath}: ${(err as Error).message}`, { cause: err }); + } +} + +// Resolves the pinned `@microsoft/dynwinrt` version by reading the winapp-npm +// package's own dependencies (the wrapper bundles dynwinrt-codegen, and +// dynwinrt shares the same pin). Mirrors the C# `NpmWrapperVersionProvider`. +export function getDynWinrtVersionPin(): string { + // The dist/jsbindings/runtime-dep-injector.js → resolve back to package.json. + // __dirname is dist/jsbindings/ in prod, src/jsbindings/ in test/dev. + // Walk up looking for the winapp-npm package.json. + const start = __dirname; + let dir = start; + const root = path.parse(dir).root; + for (;;) { + const candidate = path.join(dir, 'package.json'); + if (fs.existsSync(candidate)) { + try { + const parsed = JSON.parse(fs.readFileSync(candidate, 'utf8')) as Record; + if (parsed.name === '@microsoft/winappcli') { + const deps = parsed.dependencies; + if (deps && typeof deps === 'object') { + const v = (deps as Record)['@microsoft/dynwinrt-codegen']; + if (typeof v === 'string' && v.trim()) { + return v; + } + } + throw new Error( + `${candidate} is the @microsoft/winappcli package.json but has no @microsoft/dynwinrt-codegen pin.` + ); + } + } catch (err) { + // Wrong package.json; keep walking. + if (err instanceof Error && err.message.includes('@microsoft/dynwinrt-codegen pin')) { + throw err; + } + } + } + if (dir === root) { + break; + } + const parent = path.dirname(dir); + if (parent === dir) { + break; + } + dir = parent; + } + throw new Error( + `Could not locate the @microsoft/winappcli package.json near ${start}. ` + + 'This typically means the npm wrapper is running outside its install layout.' + ); +} + +// Hint formatting helper — keeps cli-side glue free of switch statements. +export interface RuntimeDependencyHint { + /** ANSI-friendly message to print. */ + message: string; + /** True when the user should run ` install` to materialize a new dep. */ + needsInstall: boolean; +} + +export function formatRuntimeDependencyHint( + outcome: RuntimeDependencyOutcome, + packageName: string, + pinnedVersion: string | undefined, + installCommand: string +): RuntimeDependencyHint { + const eol = os.EOL; + switch (outcome) { + case 'added': + return { + message: `✅ Added ${packageName}@${pinnedVersion} to your package.json dependencies. Run \`${installCommand}\` to materialize it.`, + needsInstall: true, + }; + case 'alreadyPresent': + return { + message: `✅ ${packageName} already declared in package.json dependencies — leaving it alone.`, + needsInstall: false, + }; + case 'presentInDevDependencies': + return { + message: `ℹ️ ${packageName} is in devDependencies — generated bindings need it as a production dep. Move it manually.`, + needsInstall: false, + }; + case 'noPackageJson': + return { + message: `⚠️ No package.json found in workspace. Generated bindings will fail to resolve ${packageName} at runtime.${eol} Run \`npm init -y\` first, then re-run \`winapp restore\` to add the dependency.`, + needsInstall: false, + }; + default: { + const _exhaustive: never = outcome; + return { message: `Unknown outcome: ${String(_exhaustive)}`, needsInstall: false }; + } + } +} diff --git a/src/winapp-npm/src/jsbindings/spinner.ts b/src/winapp-npm/src/jsbindings/spinner.ts new file mode 100644 index 00000000..53aea6c5 --- /dev/null +++ b/src/winapp-npm/src/jsbindings/spinner.ts @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. +// +// Tiny TTY spinner for long-running operations (codegen). Implemented inline +// — no external dependency — using ANSI cursor escapes the rest of the +// wrapper already relies on (`\x1b[2K`, `\x1b[?25l/h`). +// +// Behaviour matrix: +// * TTY → braille frames at 80ms, line cleared on stop(). +// * non-TTY (CI etc) → single static line written on start, stop() is a no-op. +// * SIGINT (Ctrl+C) → cursor restored before re-raising the signal so the +// terminal isn't left with a hidden caret. + +const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; +const FRAME_INTERVAL_MS = 80; + +export interface Spinner { + /** Clears the spinner line (TTY) or no-op (non-TTY). Idempotent. */ + stop: () => void; +} + +export interface SpinnerOptions { + /** Defaults to process.stdout. */ + stream?: NodeJS.WriteStream; +} + +/** + * Starts a TTY spinner showing the given text. Returns a handle whose `stop()` + * wipes the line and restores the cursor. Always pair start/stop in a `try/finally`. + * + * On non-TTY streams (pipes, files, CI), writes a single static line so the user + * still sees what's happening — no spinner animation, no line wipe. + */ +export function startSpinner(text: string, options: SpinnerOptions = {}): Spinner { + const stream = options.stream ?? process.stdout; + + if (!stream.isTTY) { + stream.write(`${text}\n`); + return { stop: () => {} }; + } + + let frame = 0; + let stopped = false; + + // Hide cursor for the duration of the spinner. + stream.write('\x1b[?25l'); + + const render = (): void => { + // \r → carriage return; \x1b[2K → clear entire line. Together they + // overwrite whatever we last drew without leaving stray trailing chars. + stream.write(`\r\x1b[2K${FRAMES[frame % FRAMES.length]} ${text}`); + frame++; + }; + + render(); + const handle = setInterval(render, FRAME_INTERVAL_MS); + + // Best-effort cleanup on Ctrl+C: clear the line + restore cursor, then + // re-raise SIGINT so the process exits with the conventional 130 code. + const onSigint = (): void => { + if (stopped) return; + stopped = true; + clearInterval(handle); + stream.write('\r\x1b[2K\x1b[?25h'); + process.removeListener('SIGINT', onSigint); + process.kill(process.pid, 'SIGINT'); + }; + process.once('SIGINT', onSigint); + + return { + stop: () => { + if (stopped) return; + stopped = true; + clearInterval(handle); + // Clear current line + show cursor again. + stream.write('\r\x1b[2K\x1b[?25h'); + process.removeListener('SIGINT', onSigint); + }, + }; +} diff --git a/src/winapp-npm/src/jsbindings/winmd-policy.ts b/src/winapp-npm/src/jsbindings/winmd-policy.ts new file mode 100644 index 00000000..7d37d3dd --- /dev/null +++ b/src/winapp-npm/src/jsbindings/winmd-policy.ts @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. +// +// Categorizes NuGet packages into how their .winmd files should be fed to +// dynwinrt-codegen: `emit` (--winmd), `refOnly` (--ref for type resolution +// but no generated bindings), or `skip` (dropped entirely). +// +// Ported from C# `JsBindingsPresets.cs`. The classification used to live in +// the native CLI and was applied as it wrote the lockfile; now the native +// only writes the raw NuGet inventory and the npm wrapper applies policy +// at codegen time. + +import * as path from 'path'; + +export type WinmdPackageCategory = 'emit' | 'refOnly' | 'skip'; + +// Built-in denylists. User `winapp.jsBindings` overrides layer on top. +const DEFAULT_REF_ONLY_PACKAGES = new Set( + ['Microsoft.WindowsAppSDK.InteractiveExperiences'].map((p) => p.toLowerCase()) +); + +const DEFAULT_SKIPPED_PACKAGES = new Set(['Microsoft.WindowsAppSDK.WinUI'].map((p) => p.toLowerCase())); + +export interface PackageCategoryOverrides { + skip?: string[]; + refOnly?: string[]; + emit?: string[]; +} + +function lowercaseSet(values: readonly string[] | undefined): Set | undefined { + if (!values || values.length === 0) { + return undefined; + } + return new Set(values.map((v) => v.toLowerCase())); +} + +// Categorize a single package ID. Precedence: +// force-emit > skip > refOnly > emit (default) +export function classifyPackage(packageId: string, overrides?: PackageCategoryOverrides): WinmdPackageCategory { + if (!packageId || !packageId.trim()) { + return 'emit'; + } + const id = packageId.toLowerCase(); + const skip = lowercaseSet(overrides?.skip); + const refOnly = lowercaseSet(overrides?.refOnly); + const forceEmit = lowercaseSet(overrides?.emit); + + // Force-emit always wins — lets users opt back in to a denylisted package. + if (forceEmit?.has(id)) { + return 'emit'; + } + if (DEFAULT_SKIPPED_PACKAGES.has(id) || skip?.has(id)) { + return 'skip'; + } + if (DEFAULT_REF_ONLY_PACKAGES.has(id) || refOnly?.has(id)) { + return 'refOnly'; + } + return 'emit'; +} + +// Given a winmd file path and the NuGet cache root, return the package ID +// (lowercased) by extracting the first path segment under the cache root. +// Returns null when the path is not under the cache (e.g. user winmds). +export function extractPackageIdFromPath(winmdPath: string, nugetCacheRoot?: string): string | null { + if (!winmdPath || !winmdPath.trim()) { + return null; + } + + if (nugetCacheRoot && nugetCacheRoot.trim()) { + try { + const full = path.resolve(winmdPath); + const root = path.resolve(nugetCacheRoot).replace(/[\\/]+$/, ''); + const rootPrefix = root + path.sep; + // Case-insensitive compare for Windows path conventions. + if (full.toLowerCase().startsWith(rootPrefix.toLowerCase())) { + const rel = full.substring(rootPrefix.length); + const firstSep = rel.search(/[\\/]/); + return firstSep > 0 ? rel.substring(0, firstSep) : rel; + } + } catch { + // Fall through to legacy heuristic. + } + } + + // Legacy heuristic: scan for a literal "packages" segment. + const segs = winmdPath.split(/[\\/]/).filter((s) => s.length > 0); + for (let i = 0; i < segs.length - 1; i++) { + if (segs[i].toLowerCase() === 'packages') { + return segs[i + 1]; + } + } + return null; +} + +export interface WinmdPartition { + emit: string[]; + refOnly: string[]; + skipped: string[]; +} + +// Partition a list of winmd paths by category. `emitScope` (when provided) +// demotes out-of-scope emit packages to refOnly so codegen still sees their +// metadata for cross-package type resolution. Skip/refOnly classifications +// take precedence over scope. +export function partitionByPackageCategory( + winmds: readonly string[], + options?: { + overrides?: PackageCategoryOverrides; + nugetCacheRoot?: string; + emitScope?: readonly string[]; + } +): WinmdPartition { + const overrides = options?.overrides; + const nugetCacheRoot = options?.nugetCacheRoot; + const scope = lowercaseSet(options?.emitScope); + + const emit: string[] = []; + const refOnly: string[] = []; + const skipped: string[] = []; + + for (const w of winmds) { + const pkg = extractPackageIdFromPath(w, nugetCacheRoot); + let cat: WinmdPackageCategory = pkg === null ? 'emit' : classifyPackage(pkg, overrides); + + if (scope && cat === 'emit' && pkg !== null && !scope.has(pkg.toLowerCase())) { + cat = 'refOnly'; + } + + if (cat === 'skip') { + skipped.push(w); + } else if (cat === 'refOnly') { + refOnly.push(w); + } else { + emit.push(w); + } + } + + return { emit, refOnly, skipped }; +} diff --git a/src/winapp-npm/src/winapp-commands.ts b/src/winapp-npm/src/winapp-commands.ts index e9164287..71acf95c 100644 --- a/src/winapp-npm/src/winapp-commands.ts +++ b/src/winapp-npm/src/winapp-commands.ts @@ -256,7 +256,7 @@ export interface InitOptions extends CommonOptions { } /** - * Start here for initializing a Windows app with required setup. Sets up everything needed for Windows app development: creates Package.appxmanifest with default assets, downloads Windows SDK and Windows App SDK packages, and generates projections. When SDK packages are managed (--setup-sdks stable/preview/experimental), also creates winapp.yaml to pin versions for 'restore'/'update'; with --setup-sdks none (e.g., for Rust/Tauri projects that bring their own SDK bindings), no winapp.yaml is created. When invoked via the \@microsoft/winappcli npm package (npx winapp init), additionally asks whether to generate C++ projections, JS/TS bindings, or both. Interactive by default (use --use-defaults to skip prompts). Use 'restore' instead if you cloned a repo that already has winapp.yaml. Use 'manifest generate' if you only need a manifest, or 'cert generate' if you need a development certificate for code signing. + * Start here for initializing a Windows app with required setup. Sets up everything needed for Windows app development: creates Package.appxmanifest with default assets, downloads Windows SDK and Windows App SDK packages, and generates projections. When SDK packages are managed (--setup-sdks stable/preview/experimental), also creates winapp.yaml to pin versions for 'restore'/'update'; with --setup-sdks none (e.g., for Rust/Tauri projects that bring their own SDK bindings), no winapp.yaml is created. Interactive by default (use --use-defaults to skip prompts). Use 'restore' instead if you cloned a repo that already has winapp.yaml. Use 'manifest generate' if you only need a manifest, or 'cert generate' if you need a development certificate for code signing. */ export async function init(options: InitOptions = {}): Promise { const args: string[] = ['init']; From 9d739d5fb78a1c4ee31025b0689fa0a69c1ca21a Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 21 May 2026 16:24:26 +0800 Subject: [PATCH 12/27] update doc --- .github/plugin/agents/winapp.agent.md | 4 ++-- .github/plugin/skills/winapp-cli/setup/SKILL.md | 5 +++++ docs/fragments/skills/winapp-cli/setup.md | 5 +++++ docs/guides/electron/jsbindings.md | 8 +++++++- docs/js-bindings.md | 12 +++++++++--- docs/usage.md | 6 ++++-- 6 files changed, 32 insertions(+), 8 deletions(-) diff --git a/.github/plugin/agents/winapp.agent.md b/.github/plugin/agents/winapp.agent.md index 4b91114c..31e1511a 100644 --- a/.github/plugin/agents/winapp.agent.md +++ b/.github/plugin/agents/winapp.agent.md @@ -102,13 +102,13 @@ Want to inspect or interact with a running app's UI? **Key options:** `--setup-sdks stable|preview|experimental|none` **Requires:** `winapp.yaml` -### JS/TS bindings (npm-only, via `init` + `restore`) +### JS/TS bindings (npm-only, via `init` / `restore` / `node generate-bindings`) **Purpose:** Generate typed JS/TS WinRT bindings (via `@microsoft/dynwinrt-codegen`) so Node / Electron apps can call WinRT APIs directly without a native build step. **When to use:** Inside a Node / Electron project after `npx winapp init`. **How to enable:** - **Fresh init via npm shim** (`npx winapp init`) shows an interactive yes/no prompt — `Add JS/TypeScript bindings to this project? [Y/n]:`. Press Enter (or pass `--use-defaults`) to opt in; the wrapper writes a default `"winapp": { "jsBindings": {} }` namespace (covering the full Windows App SDK) into `package.json`. C++ projections are always generated regardless of the answer. - **Existing workspace:** edit `package.json` to add `"winapp": { "jsBindings": {} }` (the empty object opts in with full-SDK defaults). Then run `npx winapp restore` — it re-runs codegen against the existing config without modifying it. -- **Re-run codegen** after editing `winapp.jsBindings.packages` / `extraTypes` / `additionalWinmds` by hand: `npx winapp restore`. +- **Re-run codegen** after editing `winapp.jsBindings.packages` / `extraTypes` / `additionalWinmds` by hand: `npx winapp node generate-bindings` is the fast path — it reuses the cached `.winapp/winmds.lock.json` and skips the NuGet download / cppwinrt header regen that `winapp restore` does. Use `npx winapp restore` instead when you changed `winapp.yaml` (packages, sdkVersion, etc.) so the lockfile is refreshed first. **Notes:** npm-only — the interactive prompt only fires when invoked through `npx winapp …`. Standalone winget / installer builds do not generate JS bindings. Codegen always auto-injects `@microsoft/dynwinrt` as a production dep into `package.json`. See [JS bindings docs](https://github.com/microsoft/winappcli/blob/main/docs/js-bindings.md) for the full `winapp.jsBindings` schema. ### `winapp package ` (alias: `winapp pack`) diff --git a/.github/plugin/skills/winapp-cli/setup/SKILL.md b/.github/plugin/skills/winapp-cli/setup/SKILL.md index 4137d486..976e5050 100644 --- a/.github/plugin/skills/winapp-cli/setup/SKILL.md +++ b/.github/plugin/skills/winapp-cli/setup/SKILL.md @@ -70,6 +70,11 @@ npx winapp init --use-defaults # After editing winapp.jsBindings in package.json by hand (or pulling a # teammate's package.json), regenerate bindings without re-prompting: +# Fast path — reuses the cached lockfile, no NuGet / cppwinrt re-run. +npx winapp node generate-bindings + +# Use the full restore instead if you also changed winapp.yaml (packages, +# sdkVersion, ...) — it refreshes the lockfile before re-running codegen. npx winapp restore ``` diff --git a/docs/fragments/skills/winapp-cli/setup.md b/docs/fragments/skills/winapp-cli/setup.md index f3b6d5b2..925caa0d 100644 --- a/docs/fragments/skills/winapp-cli/setup.md +++ b/docs/fragments/skills/winapp-cli/setup.md @@ -65,6 +65,11 @@ npx winapp init --use-defaults # After editing winapp.jsBindings in package.json by hand (or pulling a # teammate's package.json), regenerate bindings without re-prompting: +# Fast path — reuses the cached lockfile, no NuGet / cppwinrt re-run. +npx winapp node generate-bindings + +# Use the full restore instead if you also changed winapp.yaml (packages, +# sdkVersion, ...) — it refreshes the lockfile before re-running codegen. npx winapp restore ``` diff --git a/docs/guides/electron/jsbindings.md b/docs/guides/electron/jsbindings.md index 611b4ce6..34aec9cb 100644 --- a/docs/guides/electron/jsbindings.md +++ b/docs/guides/electron/jsbindings.md @@ -148,9 +148,15 @@ The generated `bindings/` files are committed-or-gitignored at your discretion ( - You add or remove entries in `winapp.jsBindings.packages` / `additionalWinmds` / `extraTypes` (in `package.json`) - The codegen itself is upgraded (`npm update @microsoft/dynwinrt-codegen`) -In all cases, re-run `restore` — it picks up the current `winapp.yaml` and `package.json` (neither file is mutated) and re-runs codegen: +In all cases, re-run codegen — it picks up the current `winapp.yaml` and `package.json` (neither file is mutated): ```bash +# Fast path: only re-runs dynwinrt-codegen against the cached lockfile. +# Use this after editing only `winapp.jsBindings` in package.json. +npx winapp node generate-bindings + +# Full restore: also refreshes the lockfile (NuGet + cppwinrt headers). +# Use this whenever you change `winapp.yaml` (packages, sdkVersion, ...). npx winapp restore ``` diff --git a/docs/js-bindings.md b/docs/js-bindings.md index 388edf90..a07d0cbf 100644 --- a/docs/js-bindings.md +++ b/docs/js-bindings.md @@ -153,13 +153,19 @@ This is the right pattern when the vendor ships a 200 MB winmd and you only want ### 6. Re-run codegen after editing `package.json` -Any time you edit the `winapp.jsBindings` namespace (add a package, swap to a different scope, add an `extraTypes` entry), re-run: +Any time you edit the `winapp.jsBindings` namespace (add a package, swap to a different scope, add an `extraTypes` entry), re-run codegen. The fast path skips the NuGet download + cppwinrt header regen and only re-invokes `dynwinrt-codegen`: ```bash -npx winapp restore +npx winapp node generate-bindings ``` -`restore` reads the existing JSON without modifying it, re-discovers winmds, and re-runs codegen — the output directory is replaced atomically (stage-then-swap; previous bindings are preserved on codegen failure). +It reads the existing JSON without modifying it, replays the cached winmd inventory from `.winapp/winmds.lock.json`, and re-runs codegen — the output directory is replaced atomically (stage-then-swap; previous bindings are preserved on codegen failure). + +If you also changed `winapp.yaml` (`packages`, `sdkVersion`, …) the lockfile is stale, so run the full restore instead — it refreshes the lockfile **and** re-runs codegen in one step: + +```bash +npx winapp restore +``` --- diff --git a/docs/usage.md b/docs/usage.md index d4746cab..5b87d711 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -44,7 +44,7 @@ When invoked via the `@microsoft/winappcli` npm package (i.e. `npx winapp init` Add JS/TypeScript bindings to this project? [Y/n]: ``` -Picking **Yes** writes a default `"winapp.jsBindings"` namespace to `package.json` and runs `dynwinrt-codegen` to emit JS/TS wrappers. C++ projections (cppwinrt headers/libs/runtimes) are generated either way — there is no "JS only" mode; the JS bindings are an addition on top of the standard native workspace. Subsequent `winapp restore` calls re-run codegen against the pinned packages. +Picking **Yes** writes a default `"winapp.jsBindings"` namespace to `package.json` and runs `dynwinrt-codegen` to emit JS/TS wrappers. C++ projections (cppwinrt headers/libs/runtimes) are generated either way — there is no "JS only" mode; the JS bindings are an addition on top of the standard native workspace. Subsequent `winapp restore` calls re-run codegen against the pinned packages, or use `winapp node generate-bindings` for fast codegen-only re-runs after editing `winapp.jsBindings`. See the [JS bindings reference](js-bindings.md) for the full schema (`packages`, `skip`, `refOnly`, `extraTypes`, etc.) and the [Electron JS bindings guide](guides/electron/jsbindings.md) for the end-to-end workflow. @@ -167,9 +167,11 @@ JS/TS bindings are configured by declaring a `"winapp": { "jsBindings": {...} }` |---|---| | Bootstrap a fresh workspace with bindings | `npx winapp init` (answer **Y** at the prompt; default is **Y**) | | Add JS bindings to an existing workspace | Edit `package.json` to add `"winapp": { "jsBindings": {} }` (the empty object opts in with full-SDK defaults), then run `npx winapp restore` | -| Re-run codegen after editing `winapp.jsBindings` | `npx winapp restore` | +| Re-run codegen after editing `winapp.jsBindings` (fast path) | `npx winapp node generate-bindings` | | Re-run codegen as part of full restore (also handles NuGet + cppwinrt) | `npx winapp restore` | +> **Which one should I run?** `winapp node generate-bindings` only re-runs `dynwinrt-codegen` using the cached `.winapp/winmds.lock.json` — it's the right choice after editing only the `winapp.jsBindings` block in `package.json`. Use `winapp restore` whenever you change `winapp.yaml` (packages, sdkVersion, etc.) so the lockfile is refreshed first. + **What runs during `restore` when `winapp.jsBindings` is declared:** - Reads the existing `winapp.jsBindings` namespace from `package.json` (no mutation) From f1babf80bbb780257ca1e86b1c21ba49f8e58a32 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 21 May 2026 20:25:36 +0800 Subject: [PATCH 13/27] fix all commtents --- .github/plugin/agents/winapp.agent.md | 6 +- .../fragments/skills/winapp-cli/frameworks.md | 4 +- docs/guides/electron/jsbindings.md | 16 +- docs/js-bindings.md | 2 +- docs/usage.md | 2 +- samples/electron/test.Tests.ps1 | 116 +++- .../WinApp.Cli.Tests/MsixServiceTests.cs | 33 +- .../WinappConfigDocumentTests.cs | 614 ++++++++++++++++++ .../WinApp.Cli/Helpers/ManifestHelper.cs | 8 +- .../WinApp.Cli/Helpers/PathSafety.cs | 50 ++ .../WinApp.Cli/Services/MsixService.cs | 23 +- .../Services/WinmdsLockfileService.cs | 25 +- ...soft.Windows.SDK.BuildTools.WinApp.targets | 104 ++- src/winapp-NuGet/tests/NuGet.Tests.ps1 | 63 ++ src/winapp-npm/src/cli-args.ts | 107 +++ src/winapp-npm/src/cli.ts | 148 +++-- .../src/jsbindings/codegen-runner.ts | 238 ++++--- .../src/jsbindings/lockfile-reader.ts | 121 +++- src/winapp-npm/src/jsbindings/orchestrator.ts | 72 +- .../src/jsbindings/package-json-config.ts | 163 ++--- .../src/jsbindings/package-json-doc.ts | 200 ++++++ src/winapp-npm/src/jsbindings/path-safety.ts | 74 +++ .../src/jsbindings/runtime-dep-injector.ts | 96 +-- src/winapp-npm/src/jsbindings/winmd-policy.ts | 61 +- .../src/jsbindings/yaml-packages-hash.ts | 231 +++++++ src/winapp-npm/src/winapp-cli-utils.ts | 6 +- 26 files changed, 2157 insertions(+), 426 deletions(-) create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/WinappConfigDocumentTests.cs create mode 100644 src/winapp-npm/src/cli-args.ts create mode 100644 src/winapp-npm/src/jsbindings/package-json-doc.ts create mode 100644 src/winapp-npm/src/jsbindings/yaml-packages-hash.ts diff --git a/.github/plugin/agents/winapp.agent.md b/.github/plugin/agents/winapp.agent.md index 31e1511a..80b82299 100644 --- a/.github/plugin/agents/winapp.agent.md +++ b/.github/plugin/agents/winapp.agent.md @@ -29,7 +29,7 @@ Does the project already have an appxmanifest.xml? ├─ Has winapp.yaml, cloned/pulled but .winapp/ folder is missing? │ └─ winapp restore ├─ Want to add typed JS/TypeScript WinRT bindings to an existing workspace? - │ └─ Edit package.json to add `"winapp": { "jsBindings": {} }`, then run `npx winapp restore` + │ └─ npx winapp node generate-bindings (adds default `"winapp": { "jsBindings": {} }` to package.json on first use, then generates. Requires a prior `winapp restore` so the winmd lockfile exists.) ├─ Want to check for newer SDK versions? │ └─ winapp update ├─ Only need an appxmanifest.xml (no SDKs, no cert, no config)? @@ -107,7 +107,7 @@ Want to inspect or interact with a running app's UI? **When to use:** Inside a Node / Electron project after `npx winapp init`. **How to enable:** - **Fresh init via npm shim** (`npx winapp init`) shows an interactive yes/no prompt — `Add JS/TypeScript bindings to this project? [Y/n]:`. Press Enter (or pass `--use-defaults`) to opt in; the wrapper writes a default `"winapp": { "jsBindings": {} }` namespace (covering the full Windows App SDK) into `package.json`. C++ projections are always generated regardless of the answer. -- **Existing workspace:** edit `package.json` to add `"winapp": { "jsBindings": {} }` (the empty object opts in with full-SDK defaults). Then run `npx winapp restore` — it re-runs codegen against the existing config without modifying it. +- **Existing workspace:** run `npx winapp node generate-bindings`. It adds a default `"winapp": { "jsBindings": {} }` namespace to `package.json` (covering the full Windows App SDK) on first use, then immediately generates against the cached winmd lockfile. Requires a prior `winapp restore` so the lockfile exists; if not, the command tells you to run `winapp restore` first. - **Re-run codegen** after editing `winapp.jsBindings.packages` / `extraTypes` / `additionalWinmds` by hand: `npx winapp node generate-bindings` is the fast path — it reuses the cached `.winapp/winmds.lock.json` and skips the NuGet download / cppwinrt header regen that `winapp restore` does. Use `npx winapp restore` instead when you changed `winapp.yaml` (packages, sdkVersion, etc.) so the lockfile is refreshed first. **Notes:** npm-only — the interactive prompt only fires when invoked through `npx winapp …`. Standalone winget / installer builds do not generate JS bindings. Codegen always auto-injects `@microsoft/dynwinrt` as a production dep into `package.json`. See [JS bindings docs](https://github.com/microsoft/winappcli/blob/main/docs/js-bindings.md) for the full `winapp.jsBindings` schema. @@ -229,7 +229,7 @@ Want to inspect or interact with a running app's UI? ### Electron - **Setup:** `winapp init --use-defaults` → choose your Windows API access path: - - **JS bindings (easiest, npm-only):** at the `npx winapp init` prompt answer **Y** (the default — or pass `--use-defaults`). On an existing workspace, add `"winapp": { "jsBindings": {} }` to `package.json` and run `npx winapp restore`. Generates typed `bindings/*.{js,d.ts}` for the full Windows App SDK surface, callable directly from your main/renderer process via dynwinrt. No native build step. + - **JS bindings (easiest, npm-only):** at the `npx winapp init` prompt answer **Y** (the default — or pass `--use-defaults`). On an existing workspace, run `npx winapp node generate-bindings` (adds the default `winapp.jsBindings` block on first use, then generates immediately from the cached winmd lockfile). Generates typed `bindings/*.{js,d.ts}` for the full Windows App SDK surface, callable directly from your main/renderer process via dynwinrt. No native build step. - **Native addons:** `winapp node create-addon --template cs` (or `--template cpp`) for C#/C++ addons when you need full WinRT access or stateful native services. - Then: `winapp node add-electron-debug-identity` to enable identity-required APIs. - **Package:** Build with your packager (e.g., Electron Forge), then `winapp package --cert .\devcert.pfx` diff --git a/docs/fragments/skills/winapp-cli/frameworks.md b/docs/fragments/skills/winapp-cli/frameworks.md index 3e971df4..c17c994d 100644 --- a/docs/fragments/skills/winapp-cli/frameworks.md +++ b/docs/fragments/skills/winapp-cli/frameworks.md @@ -30,8 +30,8 @@ Use the **npm package** (`@Microsoft/WinAppCli`), **not** the standalone CLI. Th Quick start: ```powershell npm install --save-dev @microsoft/winappcli -npx winapp init --use-defaults # init + generate full Windows App SDK JS bindings AND C++ projections (default: Both) -# (interactive: omit --use-defaults to pick C++ / JS / Both at the prompt) +npx winapp init --use-defaults # init + JS bindings (yes) + C++ projections (always) +# (interactive: omit --use-defaults to get a Yes/No prompt for JS bindings; default is Yes) npx winapp node create-addon --template cs # create a C# native addon (for what dynwinrt can't drive — see below) npx winapp node add-electron-debug-identity # register identity for debugging ``` diff --git a/docs/guides/electron/jsbindings.md b/docs/guides/electron/jsbindings.md index 34aec9cb..1182cda0 100644 --- a/docs/guides/electron/jsbindings.md +++ b/docs/guides/electron/jsbindings.md @@ -35,7 +35,14 @@ npm install # picks up the @microsoft/dynwinrt runtime de ### Path B — Existing project (layer bindings on) -If `winapp.yaml` already exists and you want to add JS bindings, edit `package.json` to add the `winapp.jsBindings` namespace. The empty form covers the full Windows App SDK: +If `winapp.yaml` already exists and you want to add JS bindings, run `generate-bindings`. The first invocation adds a default `winapp.jsBindings` namespace to `package.json` (covering the full Windows App SDK) and then generates immediately from the winmd lockfile written by your last `winapp restore`: + +```bash +npx winapp node generate-bindings +npm install # picks up the @microsoft/dynwinrt runtime dep +``` + +If you want to customize the scope before the first generation, you can still edit `package.json` directly — the empty form covers the full Windows App SDK: ```jsonc // package.json @@ -46,12 +53,7 @@ If `winapp.yaml` already exists and you want to add JS bindings, edit `package.j } ``` -Then run `restore` — it will pick up the new namespace, run codegen, and inject the `@microsoft/dynwinrt` runtime dep into `package.json`: - -```bash -npx winapp restore -npm install -``` +…and then run `npx winapp node generate-bindings` (or `npx winapp restore` if you also need to refresh NuGet packages / the winmd lockfile). ### What you get diff --git a/docs/js-bindings.md b/docs/js-bindings.md index a07d0cbf..aa97f0a6 100644 --- a/docs/js-bindings.md +++ b/docs/js-bindings.md @@ -375,7 +375,7 @@ In addition, `winapp restore` writes `.winapp/winmds.lock.json` — a human-read | `winapp` refuses to write into the output directory | The output directory is non-empty and lacks a `.dynwinrt-managed` marker — winapp won't wipe it because it might contain hand-written code. Either point `output` somewhere else, or delete the directory yourself if you're sure. | | Imports from `@microsoft/dynwinrt` fail at app runtime | Make sure you ran your package manager's install command after `init` / `restore` (so the auto-injected production dep actually downloads). The CLI prints the right command for your PM in the output. | | Vendor winmd not found | `additionalWinmds` / `additionalRefs` paths are workspace-relative or absolute. Missing files print a warning and are skipped (so a stale entry doesn't break a working restore) — re-check the path. | -| Want bindings but already ran `init` without them | Edit `package.json`, add `"winapp": { "jsBindings": {} }`, then run `npx winapp restore`. | +| Want bindings but already ran `init` without them | Run `npx winapp node generate-bindings` — it adds the default `"winapp": { "jsBindings": {} }` block on first use and generates immediately. (Requires a prior `winapp restore` so the winmd lockfile exists.) | | `package.json not found` when adding bindings | Run `npm init -y` (or your package manager's equivalent) first so the file exists, then re-run `npx winapp init`. | --- diff --git a/docs/usage.md b/docs/usage.md index 5b87d711..e28ae753 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -166,7 +166,7 @@ JS/TS bindings are configured by declaring a `"winapp": { "jsBindings": {...} }` | Want to … | Command | |---|---| | Bootstrap a fresh workspace with bindings | `npx winapp init` (answer **Y** at the prompt; default is **Y**) | -| Add JS bindings to an existing workspace | Edit `package.json` to add `"winapp": { "jsBindings": {} }` (the empty object opts in with full-SDK defaults), then run `npx winapp restore` | +| Add JS bindings to an existing workspace | `npx winapp node generate-bindings` — adds a default `"winapp": { "jsBindings": {} }` block (full-SDK scope) on first use, then generates from the cached winmd lockfile. Requires a prior `winapp restore`. | | Re-run codegen after editing `winapp.jsBindings` (fast path) | `npx winapp node generate-bindings` | | Re-run codegen as part of full restore (also handles NuGet + cppwinrt) | `npx winapp restore` | diff --git a/samples/electron/test.Tests.ps1 b/samples/electron/test.Tests.ps1 index f3ec5b63..1981e3ef 100644 --- a/samples/electron/test.Tests.ps1 +++ b/samples/electron/test.Tests.ps1 @@ -31,6 +31,7 @@ Describe "Electron Sample" { $script:sampleDir = $PSScriptRoot $script:tempDir = $null + $script:samplePhase2Dir = $null $script:appDir = $null $script:resolvedPkg = $null @@ -40,11 +41,12 @@ Describe "Electron Sample" { } AfterAll { - Set-Location $script:sampleDir - if (-not $SkipCleanup) { if ($script:tempDir) { Remove-TempTestDirectory -Path $script:tempDir } - Remove-Item -Path (Join-Path $script:sampleDir 'node_modules') -Recurse -Force -ErrorAction SilentlyContinue + # Phase 2 now runs against a temp copy of samples/electron, so the + # checked-in sample is never mutated and only the temp copy needs + # to be torn down — no git checkout / snapshot dance required. + if ($script:samplePhase2Dir) { Remove-TempTestDirectory -Path $script:samplePhase2Dir } } } @@ -281,34 +283,124 @@ Describe "Electron Sample" { } Context "Phase 2: Sample Build Check" { + BeforeAll { + if (-not $script:skip) { + # Phase 2 mutates package.json and writes generated artifacts + # (.winapp, bindings, node_modules, out). To keep the + # checked-in sample directory pristine for the contributor's + # working tree, run the entire phase against a temp copy. + # Exclude only the install/build outputs — keep the tracked + # package-lock.json so `npm ci` enforces the exact dependency + # graph contributors ship. + $script:samplePhase2Dir = New-TempTestDirectory -Prefix "electron-phase2" + $skipDirs = @('node_modules', '.winapp', 'bindings', 'out') + Get-ChildItem -Path $script:sampleDir -Force | Where-Object { + if ($_.PSIsContainer) { $skipDirs -notcontains $_.Name } + else { $true } + } | ForEach-Object { + Copy-Item -Path $_.FullName -Destination $script:samplePhase2Dir -Recurse -Force + } + } + } + It "Should install sample dependencies" -Skip:$script:skip { - Push-Location $script:sampleDir + Push-Location $script:samplePhase2Dir try { - Invoke-Expression "npm install --ignore-scripts" + # `npm ci` enforces the tracked package-lock.json so the + # exact dependency graph contributors ship is exercised + # (catches transitive regressions a relaxed `npm install` + # would mask). `--ignore-scripts` skips the sample's + # `postinstall` (`winapp restore && cert generate && + # setup-debug`) which would otherwise call the *published* + # winappcli pulled in via the devDependency pin — we + # override it with the local build below before running + # restore ourselves. + Invoke-Expression "npm ci --ignore-scripts" $LASTEXITCODE | Should -Be 0 } finally { Pop-Location } } It "Should have node_modules" -Skip:$script:skip { - Join-Path $script:sampleDir 'node_modules' | Should -Exist + Join-Path $script:samplePhase2Dir 'node_modules' | Should -Exist } It "Should have package.json" -Skip:$script:skip { - Join-Path $script:sampleDir 'package.json' | Should -Exist + Join-Path $script:samplePhase2Dir 'package.json' | Should -Exist } It "Should have forge.config.js" -Skip:$script:skip { - Join-Path $script:sampleDir 'forge.config.js' | Should -Exist + Join-Path $script:samplePhase2Dir 'forge.config.js' | Should -Exist } It "Should have appxmanifest.xml" -Skip:$script:skip { - Join-Path $script:sampleDir 'appxmanifest.xml' | Should -Exist + Join-Path $script:samplePhase2Dir 'appxmanifest.xml' | Should -Exist } - It "Should build the C# addon" -Skip:$script:skip { - Push-Location $script:sampleDir + It "Should install the locally-built winappcli on top" -Skip:$script:skip { + # Sample's devDependency pin would otherwise resolve `winapp` to + # the published version; we need the build under test. + Push-Location $script:samplePhase2Dir + try { + Install-WinappNpmPackage -PackagePath $script:resolvedPkg + Join-Path $script:samplePhase2Dir 'node_modules\.bin\winapp.cmd' | Should -Exist + } finally { Pop-Location } + } + + It "Should exercise the JS bindings flow via 'winapp restore'" -Skip:$script:skip { + # The sample ships `"winapp": { "jsBindings": {} }` in its + # package.json; restore must drive the npm-wrapper orchestrator + # end-to-end (winmd discovery → codegen → runtime-dep injection) + # so a regression to the bindings pipeline cannot silently + # survive Phase 2. + Push-Location $script:samplePhase2Dir + try { + Invoke-WinappCommand -Arguments "restore" + } finally { Pop-Location } + } + + It "Should have generated bindings/ with the managed marker" -Skip:$script:skip { + $bindingsDir = Join-Path $script:samplePhase2Dir 'bindings' + $bindingsDir | Should -Exist + (Join-Path $bindingsDir '.dynwinrt-managed') | Should -Exist ` + -Because "restore on a workspace with winapp.jsBindings must populate the managed marker" + $jsCount = (Get-ChildItem -Path $bindingsDir -Filter '*.js' -ErrorAction SilentlyContinue).Count + $jsCount | Should -BeGreaterThan 50 -Because "Default jsBindings (full WinAppSDK) should generate many JS files" + } + + It "Should inject @microsoft/dynwinrt as a runtime dep in package.json" -Skip:$script:skip { + # Bindings import @microsoft/dynwinrt at load time — restore + # must auto-inject it as a production dep so `npm ci --omit=dev` + # doesn't strip it. + $pkgPath = Join-Path $script:samplePhase2Dir 'package.json' + $pkg = Get-Content $pkgPath -Raw | ConvertFrom-Json + $pkg.dependencies.'@microsoft/dynwinrt' | Should -Not -BeNullOrEmpty ` + -Because "restore on a workspace with winapp.jsBindings must inject the runtime dep" + } + + It "Should materialize @microsoft/dynwinrt under node_modules after a follow-up install" -Skip:$script:skip { + # The injector only mutates package.json; a follow-up `npm install` + # is what actually pulls the runtime down. Re-install with + # --ignore-scripts so we don't recurse through `winapp restore` + # again, then assert the package is on disk — guarantees the + # runtime is actually resolvable at app start, not just declared. + Push-Location $script:samplePhase2Dir + try { + Invoke-Expression "npm install --ignore-scripts" + $LASTEXITCODE | Should -Be 0 + } finally { Pop-Location } + Join-Path $script:samplePhase2Dir 'node_modules\@microsoft\dynwinrt' | Should -Exist ` + -Because "the runtime dep injected by restore must actually be installable" + } + + It "Should run the full sample build (build-all)" -Skip:$script:skip { + # `build-all = build-csAddon && build-addon` — the full build the + # sample's package, package-msix, and package-msix:x64 scripts + # depend on. Building only `build-csAddon` (the previous + # assertion) leaves the C++ side untested and let a node-gyp / + # node-addon-api regression survive Phase 2. + Push-Location $script:samplePhase2Dir try { - Invoke-Expression "npm run build-csAddon" + Invoke-Expression "npm run build-all" $LASTEXITCODE | Should -Be 0 } finally { Pop-Location } } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/MsixServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/MsixServiceTests.cs index 3aec441b..705c52a4 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/MsixServiceTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/MsixServiceTests.cs @@ -687,9 +687,15 @@ public void FindManifestInDirectory_FindsAppxManifestXml() // Act var result = MsixService.FindManifestInDirectory(_tempDir); - // Assert + // Assert: FindManifestInDirectory delegates to ManifestHelper which + // probes Package.appxmanifest → AppxManifest.xml → appxmanifest.xml. + // On NTFS (case-insensitive) the AppxManifest.xml probe matches the + // on-disk "appxmanifest.xml" file, so the returned Name reflects + // whichever spelling our precedence list probed first — compare + // case-insensitively. The contract is "a .xml manifest was found". Assert.IsNotNull(result); - Assert.AreEqual("appxmanifest.xml", result.Name); + Assert.IsTrue(string.Equals("appxmanifest.xml", result.Name, StringComparison.OrdinalIgnoreCase), + $"Expected an appxmanifest.xml-family name, got '{result.Name}'"); } [TestMethod] @@ -721,6 +727,29 @@ public void FindManifestInDirectory_PrefersPackageAppxManifest_WhenBothExist() Assert.AreEqual("Package.appxmanifest", result.Name); } + [TestMethod] + public void FindManifestInDirectory_FindsAppxManifestXml_PascalCase() + { + // The NuGet targets' GetWinAppRunSupportInfo target probes + // AppxManifest.xml (Pascal-case) as one of the standard names — + // and ManifestHelper.GetWellKnownManifestFileNames lists it too. + // FindManifestInDirectory must accept the same spelling so + // `winapp run` / `create-debug-identity` find the same file the + // build outputs. + // + // On NTFS (case-insensitive) the actual returned Name may reflect + // whichever spelling our internal precedence list probed first, + // so we compare case-insensitively — the contract here is "the + // file was found", not "Name preserves the exact on-disk case". + File.WriteAllText(Path.Combine(_tempDir.FullName, "AppxManifest.xml"), ""); + + var result = MsixService.FindManifestInDirectory(_tempDir); + + Assert.IsNotNull(result); + Assert.IsTrue(string.Equals("AppxManifest.xml", result.Name, StringComparison.OrdinalIgnoreCase), + $"Expected an appxmanifest.xml-family name, got '{result.Name}'"); + } + [TestMethod] public void FindManifestInDirectory_ReturnsNull_WhenNoManifest() { diff --git a/src/winapp-CLI/WinApp.Cli.Tests/WinappConfigDocumentTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/WinappConfigDocumentTests.cs new file mode 100644 index 00000000..ebaf8762 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/WinappConfigDocumentTests.cs @@ -0,0 +1,614 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.Models; +using WinApp.Cli.Services; + +// CA1861 ("avoid constant arrays as arguments") is real perf advice in hot +// paths, but these tests use one-shot literal arrays as fixture data and +// extracting each to a `static readonly` field would make the round-trip +// cases noticeably harder to read. Suppress at file scope. +#pragma warning disable CA1861 + +namespace WinApp.Cli.Tests; + +// Direct round-trip tests for the WinappConfigDocument YAML grammar. +// +// The native CLI owns a tiny hand-rolled YAML subset (just `packages:`) +// because pulling in YamlDotNet would balloon the NativeAOT trim surface. +// That makes parser/renderer drift the most likely failure mode, so this +// suite pins: +// * SanitizeScalar (inline `#` strip, quoted-scalar peel, apostrophe +// escape, plain-vs-quoted comment handling) +// * QuoteScalar (drive-letter paths, leading dash, reserved YAML +// booleans, numeric / boolean-looking versions) +// * Parse / Render round-trips that survive unknown top-level keys and +// inline comments on the `packages:` header +[TestClass] +public class WinappConfigDocumentTests +{ + private static WinappConfig RoundTrip(WinappConfig cfg) + { + var yaml = new WinappConfigDocument(cfg).Render(); + return WinappConfigDocument.Parse(yaml).Config; + } + + // --------------------------------------------------------------------- + // SanitizeScalar — inline `#` comment stripping + // --------------------------------------------------------------------- + + [TestMethod] + public void Parse_PackageVersionWithInlineComment_StripsComment() + { + // A plain (unquoted) `# comment` must be dropped from the version + // scalar — otherwise the comment text gets baked into the stored + // value and lockfile + restore reproduce the wrong pin on the next + // run. + var yaml = string.Join('\n', new[] + { + "packages:", + " - name: Microsoft.WindowsAppSDK", + " version: 1.8.39 # pinned for compat", + "", + }); + + var cfg = WinappConfigDocument.Parse(yaml).Config; + + Assert.AreEqual(1, cfg.Packages.Count); + Assert.AreEqual("Microsoft.WindowsAppSDK", cfg.Packages[0].Name); + Assert.AreEqual("1.8.39", cfg.Packages[0].Version); + } + + [TestMethod] + public void Parse_PackagesHeaderWithInlineComment_StillCollectsEntries() + { + // `packages: # SDK pins` must still open the packages section. + // Pre-fix the parser required an exact-string match and silently + // dropped every subsequent `- name:` / `version:` line. + var yaml = string.Join('\n', new[] + { + "packages: # SDK pins", + " - name: Microsoft.WindowsAppSDK", + " version: 1.8.39", + "", + }); + + var cfg = WinappConfigDocument.Parse(yaml).Config; + + Assert.AreEqual(1, cfg.Packages.Count, + "packages: with inline comment must still collect entries"); + Assert.AreEqual("Microsoft.WindowsAppSDK", cfg.Packages[0].Name); + Assert.AreEqual("1.8.39", cfg.Packages[0].Version); + } + + [TestMethod] + public void SanitizeScalar_QuotedHashInValue_PreservesHash() + { + // A `#` *inside* a quoted scalar is literal — it is NOT a comment + // boundary. The unit-level guard pins this so we don't reintroduce + // a "strip after first #" regression. + Assert.AreEqual("weird # name", + WinappConfigDocument.SanitizeScalar(" \"weird # name\"")); + Assert.AreEqual("note # foo", + WinappConfigDocument.SanitizeScalar(" 'note # foo'")); + } + + [TestMethod] + public void SanitizeScalar_PlainApostropheValue_StripsInlineComment() + { + // A plain (unquoted) `foo's-bar # comment` must drop the comment + // — the apostrophe is NOT a quote opener and must not suppress + // the `# comment` boundary detector. + Assert.AreEqual("foo's-bar", + WinappConfigDocument.SanitizeScalar(" foo's-bar # this is a comment")); + } + + // --------------------------------------------------------------------- + // SanitizeScalar — quoted-scalar peeling (incl. single-quote escape) + // --------------------------------------------------------------------- + + [TestMethod] + public void SanitizeScalar_SingleQuotedWithDoubledApostrophe_Unescapes() + { + // YAML single-quoted scalars use `''` as the literal-`'` escape. + // QuoteScalar emits `'O''Brien'`; SanitizeScalar must reverse it + // so round-trip is stable. + Assert.AreEqual("O'Brien", + WinappConfigDocument.SanitizeScalar(" 'O''Brien'")); + } + + [TestMethod] + public void SanitizeScalar_DoubleQuotedSimple_Peels() + { + Assert.AreEqual("hello", + WinappConfigDocument.SanitizeScalar(" \"hello\"")); + } + + [TestMethod] + public void SanitizeScalar_AsymmetricQuotes_DoesNotPeel() + { + // `it's` (plain) must NOT become `it`s` — the outer-quote peel only + // runs when both ends match the opener. + Assert.AreEqual("it's", + WinappConfigDocument.SanitizeScalar(" it's")); + } + + // --------------------------------------------------------------------- + // QuoteScalar — values the renderer MUST quote + // --------------------------------------------------------------------- + + [TestMethod] + public void RoundTrip_PackageWithDriveLetterColon_PreservedAsString() + { + // `C:\winmds\extra` contains `:` so the renderer must quote; + // otherwise the next load re-parses it as a mapping and drops + // the value. We cover this via the packages: list because that + // is the only field today; it exercises the same QuoteScalar / + // SanitizeScalar pipeline as any other scalar. + var cfg = new WinappConfig + { + Packages = + { + new PackagePin { Name = @"C:\vendor\Foo", Version = "1.0.0" }, + }, + }; + + var rt = RoundTrip(cfg); + + Assert.AreEqual(1, rt.Packages.Count); + Assert.AreEqual(@"C:\vendor\Foo", rt.Packages[0].Name); + Assert.AreEqual("1.0.0", rt.Packages[0].Version); + } + + [TestMethod] + public void RoundTrip_PackageNameContainingApostrophe_PreservesValue() + { + var cfg = new WinappConfig + { + Packages = + { + new PackagePin { Name = "Some.Vendor's.Package", Version = "1.0.0" }, + }, + }; + + var rt = RoundTrip(cfg); + + Assert.AreEqual("Some.Vendor's.Package", rt.Packages[0].Name); + } + + [TestMethod] + public void RoundTrip_PackageNameContainingHashChar_PreservesValue() + { + // An unquoted `#` introduces a comment; the renderer must quote + // and the parser must NOT strip the `#` from the quoted value. + var cfg = new WinappConfig + { + Packages = + { + new PackagePin { Name = "Vendor.C#-Package", Version = "1.0.0" }, + }, + }; + + var rt = RoundTrip(cfg); + + Assert.AreEqual("Vendor.C#-Package", rt.Packages[0].Name); + } + + [TestMethod] + public void RoundTrip_NumericLookingVersion_PreservedAsString() + { + // A version like `1.0` would otherwise re-parse as the double + // `1.0` and lose its string identity. NeedsQuoting must catch + // numeric-looking values and quote them. + var cfg = new WinappConfig + { + Packages = + { + new PackagePin { Name = "Vendor.Pkg", Version = "1.0" }, + new PackagePin { Name = "Vendor.IntPkg", Version = "42" }, + }, + }; + + var rt = RoundTrip(cfg); + + Assert.AreEqual("1.0", rt.Packages[0].Version); + Assert.AreEqual("42", rt.Packages[1].Version); + } + + [TestMethod] + public void RoundTrip_ReservedYamlBooleanLikeValue_PreservedAsString() + { + // A version like `no` (unusual but legal) would be re-parsed as + // the YAML 1.1 boolean false; the renderer must quote. + var cfg = new WinappConfig + { + Packages = + { + new PackagePin { Name = "no", Version = "yes" }, + }, + }; + + var rt = RoundTrip(cfg); + + Assert.AreEqual("no", rt.Packages[0].Name); + Assert.AreEqual("yes", rt.Packages[0].Version); + } + + [TestMethod] + public void RoundTrip_ValueLeadingWithDash_PreservedAsString() + { + // A leading `-` would otherwise be parsed as a YAML list marker. + var cfg = new WinappConfig + { + Packages = + { + new PackagePin { Name = "-leading-dash-pkg", Version = "1.0.0" }, + }, + }; + + var rt = RoundTrip(cfg); + + Assert.AreEqual("-leading-dash-pkg", rt.Packages[0].Name); + } + + // --------------------------------------------------------------------- + // Parse — unknown top-level keys must not leak into known sections + // --------------------------------------------------------------------- + + [TestMethod] + public void Parse_UnknownTopLevelKey_DoesNotAbsorbItsChildren() + { + // A future / unknown top-level key (e.g. `jsBindings:` which now + // lives in package.json) must NOT push its children into the + // packages: section. + var yaml = string.Join('\n', new[] + { + "jsBindings:", + " packages:", + " - Microsoft.WindowsAppSDK", + "packages:", + " - name: Microsoft.WindowsAppSDK", + " version: 1.8.39", + "", + }); + + var cfg = WinappConfigDocument.Parse(yaml).Config; + + Assert.AreEqual(1, cfg.Packages.Count, + "unknown top-level key must not pollute packages"); + Assert.AreEqual("Microsoft.WindowsAppSDK", cfg.Packages[0].Name); + Assert.AreEqual("1.8.39", cfg.Packages[0].Version); + } + + [TestMethod] + public void Parse_EmptyDocument_ProducesEmptyConfig() + { + var cfg = WinappConfigDocument.Parse(string.Empty).Config; + Assert.AreEqual(0, cfg.Packages.Count); + } + + [TestMethod] + public void Parse_OnlyCommentsAndBlankLines_ProducesEmptyConfig() + { + var yaml = string.Join('\n', new[] + { + "# this is a comment", + "", + " # indented comment", + "", + }); + + var cfg = WinappConfigDocument.Parse(yaml).Config; + Assert.AreEqual(0, cfg.Packages.Count); + } + + // --------------------------------------------------------------------- + // Render — output must round-trip identically through Parse + // --------------------------------------------------------------------- + + [TestMethod] + public void Render_MultiplePackages_ParsesBackToSameConfig() + { + var cfg = new WinappConfig + { + Packages = + { + new PackagePin { Name = "Microsoft.WindowsAppSDK", Version = "1.8.39" }, + new PackagePin { Name = "Microsoft.Web.WebView2", Version = "1.0.2592.51" }, + }, + }; + + var rt = RoundTrip(cfg); + + Assert.AreEqual(2, rt.Packages.Count); + Assert.AreEqual("Microsoft.WindowsAppSDK", rt.Packages[0].Name); + Assert.AreEqual("1.8.39", rt.Packages[0].Version); + Assert.AreEqual("Microsoft.Web.WebView2", rt.Packages[1].Name); + Assert.AreEqual("1.0.2592.51", rt.Packages[1].Version); + } + + [TestMethod] + public void Render_EmptyConfig_StillEmitsPackagesHeader() + { + // `packages:` (with no entries) is the canonical empty form. + // Render must always emit it so a subsequent Parse round-trips + // to the same config. + var doc = new WinappConfigDocument(new WinappConfig()); + var yaml = doc.Render(); + + StringAssert.Contains(yaml, "packages:"); + var rt = WinappConfigDocument.Parse(yaml).Config; + Assert.AreEqual(0, rt.Packages.Count); + } + + [TestMethod] + public void Render_OutputEndsWithNewline_StableUnderRepeatedRoundTrip() + { + // Rendering must be idempotent: Parse → Render → Parse → Render + // produces the same bytes the second time. Trailing-newline drift + // is a common source of "diff churn on every save". + var cfg = new WinappConfig + { + Packages = + { + new PackagePin { Name = "Microsoft.WindowsAppSDK", Version = "1.8.39" }, + }, + }; + + var first = new WinappConfigDocument(cfg).Render(); + var second = new WinappConfigDocument(WinappConfigDocument.Parse(first).Config).Render(); + + Assert.AreEqual(first, second, "Render must be idempotent under Parse → Render"); + } + + // --------------------------------------------------------------------- + // TryParseBool — small public helper, easy regression target + // --------------------------------------------------------------------- + + [TestMethod] + public void TryParseBool_AcceptsYamlBooleanLiterals() + { + Assert.IsTrue(WinappConfigDocument.TryParseBool("true", out var v) && v); + Assert.IsTrue(WinappConfigDocument.TryParseBool("YES", out v) && v); + Assert.IsTrue(WinappConfigDocument.TryParseBool(" on ", out v) && v); + Assert.IsTrue(WinappConfigDocument.TryParseBool("1", out v) && v); + + Assert.IsTrue(WinappConfigDocument.TryParseBool("false", out v) && !v); + Assert.IsTrue(WinappConfigDocument.TryParseBool("No", out v) && !v); + Assert.IsTrue(WinappConfigDocument.TryParseBool("off", out v) && !v); + Assert.IsTrue(WinappConfigDocument.TryParseBool("0", out v) && !v); + + Assert.IsFalse(WinappConfigDocument.TryParseBool("maybe", out _)); + Assert.IsFalse(WinappConfigDocument.TryParseBool(string.Empty, out _)); + } + + [TestMethod] + public void IsTopLevelKey_AcceptsExactAndCommentedHeader() + { + Assert.IsTrue(WinappConfigDocument.IsTopLevelKey("packages:", "packages:")); + Assert.IsTrue(WinappConfigDocument.IsTopLevelKey("packages: ", "packages:")); + Assert.IsTrue(WinappConfigDocument.IsTopLevelKey("packages: # sdk pins", "packages:")); + Assert.IsFalse(WinappConfigDocument.IsTopLevelKey("packageses:", "packages:")); + Assert.IsFalse(WinappConfigDocument.IsTopLevelKey("- packages:", "packages:")); + } + + // --------------------------------------------------------------------- + // Parse — additional coverage: duplicate keys, full-grammar round-trip + // --------------------------------------------------------------------- + + [TestMethod] + public void Parse_DuplicatePackagesKey_AppendsEntriesFromBothBlocks() + { + // A pathological (or hand-edited) `winapp.yaml` may contain the + // `packages:` key more than once. Pin the documented behavior — + // the parser re-enters the section and ACCUMULATES entries — so a + // refactor doesn't accidentally drop the second block silently. + var yaml = string.Join('\n', new[] + { + "packages:", + " - name: Microsoft.WindowsAppSDK", + " version: 1.8.39", + "packages:", + " - name: Microsoft.Web.WebView2", + " version: 1.0.2592.51", + "", + }); + + var cfg = WinappConfigDocument.Parse(yaml).Config; + + Assert.AreEqual(2, cfg.Packages.Count, + "duplicate top-level packages: keys must accumulate entries (not drop the second block)"); + Assert.AreEqual("Microsoft.WindowsAppSDK", cfg.Packages[0].Name); + Assert.AreEqual("Microsoft.Web.WebView2", cfg.Packages[1].Name); + } + + [TestMethod] + public void RoundTrip_FullGrammarSurface_StableUnderAllQuotingRules() + { + // One round-trip that exercises EVERY QuoteScalar branch at once + // (drive-letter colon, apostrophe, hash, numeric-looking version, + // boolean-like version, leading-dash name) plus a plain entry. + // A regression in any single rule will surface here as a + // mis-ordered or mangled pair — single-rule tests above pin the + // exact failure mode, this one pins the COMBINED render-parse + // contract. + var cfg = new WinappConfig + { + Packages = + { + new PackagePin { Name = "Plain.Vendor.Package", Version = "1.2.3" }, + new PackagePin { Name = @"C:\vendor\Local", Version = "0.1.0" }, + new PackagePin { Name = "Some.Vendor's.Package", Version = "2.0" }, + new PackagePin { Name = "Vendor.C#-Package", Version = "42" }, + new PackagePin { Name = "-leading-dash-pkg", Version = "yes" }, + new PackagePin { Name = "no", Version = "off" }, + }, + }; + + var rt = RoundTrip(cfg); + + Assert.AreEqual(6, rt.Packages.Count); + Assert.AreEqual("Plain.Vendor.Package", rt.Packages[0].Name); + Assert.AreEqual("1.2.3", rt.Packages[0].Version); + Assert.AreEqual(@"C:\vendor\Local", rt.Packages[1].Name); + Assert.AreEqual("0.1.0", rt.Packages[1].Version); + Assert.AreEqual("Some.Vendor's.Package", rt.Packages[2].Name); + Assert.AreEqual("2.0", rt.Packages[2].Version); + Assert.AreEqual("Vendor.C#-Package", rt.Packages[3].Name); + Assert.AreEqual("42", rt.Packages[3].Version); + Assert.AreEqual("-leading-dash-pkg", rt.Packages[4].Name); + Assert.AreEqual("yes", rt.Packages[4].Version); + Assert.AreEqual("no", rt.Packages[5].Name); + Assert.AreEqual("off", rt.Packages[5].Version); + + // Second-round serialization must equal the first (idempotency + // already covered for one entry; pin it for the full-grammar case). + var firstYaml = new WinappConfigDocument(cfg).Render(); + var secondYaml = new WinappConfigDocument(WinappConfigDocument.Parse(firstYaml).Config).Render(); + Assert.AreEqual(firstYaml, secondYaml, "Render must remain idempotent across the full quoting surface"); + } + + [TestMethod] + public void Parse_InputWithoutTrailingNewline_RoundTripsStablyAndAppendsNewline() + { + // A hand-edited winapp.yaml may not end with a newline. Verify: + // * Parse succeeds (no off-by-one on the missing terminator) + // * Render appends a single trailing newline + // * A subsequent Parse → Render is byte-identical (idempotent) + // This is the splice-into-existing-content edge case the Render + // idempotency test (which starts from a Render'd string) does not + // exercise — Render already terminates with \n, so re-parsing + // never sees the "missing trailing newline" surface. + var noTrailingNewline = "packages:\n - name: Microsoft.WindowsAppSDK\n version: 1.8.39"; + Assert.IsFalse(noTrailingNewline.EndsWith('\n'), + "fixture sanity check: input must not end with a newline"); + + var firstRender = new WinappConfigDocument(WinappConfigDocument.Parse(noTrailingNewline).Config).Render(); + var secondRender = new WinappConfigDocument(WinappConfigDocument.Parse(firstRender).Config).Render(); + + Assert.IsTrue(firstRender.EndsWith('\n'), + "Render must always emit a trailing newline regardless of the source document"); + Assert.AreEqual(firstRender, secondRender, + "Parse → Render must be byte-stable across a second round-trip"); + + var rt = WinappConfigDocument.Parse(firstRender).Config; + Assert.AreEqual(1, rt.Packages.Count); + Assert.AreEqual("Microsoft.WindowsAppSDK", rt.Packages[0].Name); + Assert.AreEqual("1.8.39", rt.Packages[0].Version); + } + + // --------------------------------------------------------------------- + // File-IO splice tests — exercise the exact byte sequence + // ConfigService.Save uses (Render → PathSafety.AtomicWriteAllText with + // UTF8-no-BOM), then read the on-disk bytes back and re-parse. The + // in-memory Render() tests above can't catch BOM injection, mid-write + // truncation, or encoding drift between the writer and reader. + // --------------------------------------------------------------------- + + private static readonly System.Text.UTF8Encoding SaveEncoding = + new(encoderShouldEmitUTF8Identifier: false); + + private static string WriteWithSavePath(string tempPath, WinappConfig cfg) + { + var yaml = new WinappConfigDocument(cfg).Render(); + WinApp.Cli.Helpers.PathSafety.AtomicWriteAllText(tempPath, yaml, SaveEncoding); + return tempPath; + } + + [TestMethod] + public void Splice_IntoEmptyFile_ProducesValidYaml() + { + // A user (or a stale process) may have left an empty winapp.yaml on + // disk. ConfigService.Save must overwrite it with a complete document + // that round-trips through Parse — the empty file must not poison + // anything (header missing, BOM injected, etc). + var tempPath = Path.Combine(Path.GetTempPath(), $"winapp-splice-empty-{Guid.NewGuid():N}.yaml"); + try + { + File.WriteAllBytes(tempPath, Array.Empty()); + Assert.AreEqual(0, new FileInfo(tempPath).Length, "fixture: file must start empty"); + + var cfg = new WinappConfig(); + cfg.Packages.Add(new PackagePin { Name = "Microsoft.WindowsAppSDK", Version = "1.8.39" }); + WriteWithSavePath(tempPath, cfg); + + var bytes = File.ReadAllBytes(tempPath); + Assert.IsTrue(bytes.Length > 0, "Save must overwrite the empty file with rendered content"); + // No UTF-8 BOM: ConfigService.Utf8NoBom mirror. + Assert.IsFalse(bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF, + "Save must not emit a UTF-8 BOM"); + + var saved = File.ReadAllText(tempPath, SaveEncoding); + var rt = WinappConfigDocument.Parse(saved).Config; + Assert.AreEqual(1, rt.Packages.Count); + Assert.AreEqual("Microsoft.WindowsAppSDK", rt.Packages[0].Name); + Assert.AreEqual("1.8.39", rt.Packages[0].Version); + } + finally + { + try { File.Delete(tempPath); } catch { /* best effort */ } + } + } + + [TestMethod] + public void Splice_PreservesTrailingNewline() + { + // Save → on-disk bytes must end with '\n'. A second Save against the + // same config must produce byte-identical bytes (file-level + // idempotency, not just in-memory Render idempotency). + var tempPath = Path.Combine(Path.GetTempPath(), $"winapp-splice-newline-{Guid.NewGuid():N}.yaml"); + try + { + var cfg = new WinappConfig(); + cfg.Packages.Add(new PackagePin { Name = "Microsoft.WindowsAppSDK", Version = "1.8.39" }); + cfg.Packages.Add(new PackagePin { Name = "Microsoft.UI.Xaml", Version = "2.8.6" }); + + var firstBytes = File.ReadAllBytes(WriteWithSavePath(tempPath, cfg)); + Assert.IsTrue(firstBytes.Length > 0, "Save must produce a non-empty file"); + Assert.AreEqual((byte)'\n', firstBytes[^1], "Save must terminate the file with a trailing newline"); + + var secondBytes = File.ReadAllBytes(WriteWithSavePath(tempPath, cfg)); + CollectionAssert.AreEqual(firstBytes, secondBytes, + "Re-saving the same config must produce byte-identical on-disk content"); + } + finally + { + try { File.Delete(tempPath); } catch { /* best effort */ } + } + } + + [TestMethod] + public void Splice_NoTrailingNewline_AddsOneAndStaysIdempotent() + { + // A hand-edited winapp.yaml saved without a trailing newline must + // get one back after the next Save, and a second Save must remain + // byte-stable. This is the disk-side analogue of the + // Parse_InputWithoutTrailingNewline_RoundTripsStablyAndAppendsNewline + // in-memory test — only the file path can detect a writer/encoder + // bug that injects extra bytes during persistence. + var tempPath = Path.Combine(Path.GetTempPath(), $"winapp-splice-nonl-{Guid.NewGuid():N}.yaml"); + try + { + var seed = "packages:\n - name: Legacy.Pkg\n version: 0.1.0"; + Assert.IsFalse(seed.EndsWith('\n'), "fixture: seed must not end with a newline"); + File.WriteAllText(tempPath, seed, SaveEncoding); + Assert.AreNotEqual((byte)'\n', File.ReadAllBytes(tempPath)[^1], + "fixture: on-disk seed must not end with a newline"); + + // Re-parse the on-disk bytes and save through the production + // sequence; the new file must end with '\n' and stay stable. + var loaded = WinappConfigDocument.Parse(File.ReadAllText(tempPath, SaveEncoding)).Config; + var firstBytes = File.ReadAllBytes(WriteWithSavePath(tempPath, loaded)); + Assert.AreEqual((byte)'\n', firstBytes[^1], + "Save must append a trailing newline when the source document lacked one"); + + var secondBytes = File.ReadAllBytes(WriteWithSavePath(tempPath, loaded)); + CollectionAssert.AreEqual(firstBytes, secondBytes, + "A second Save against the now-newline-terminated file must be byte-stable"); + } + finally + { + try { File.Delete(tempPath); } catch { /* best effort */ } + } + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/ManifestHelper.cs b/src/winapp-CLI/WinApp.Cli/Helpers/ManifestHelper.cs index 0864b74f..ec0ddf69 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/ManifestHelper.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/ManifestHelper.cs @@ -8,11 +8,15 @@ namespace WinApp.Cli.Helpers; ///
internal static class ManifestHelper { - private static readonly string[] ManifestNames = ["Package.appxmanifest", "appxmanifest.xml"]; + private static readonly string[] ManifestNames = ["Package.appxmanifest", "AppxManifest.xml", "appxmanifest.xml"]; /// /// Finds an appxmanifest file in the specified directory. - /// Checks for Package.appxmanifest first, then appxmanifest.xml. + /// Checks for Package.appxmanifest first, then AppxManifest.xml, + /// then appxmanifest.xml. The two .xml spellings are equivalent on + /// case-insensitive filesystems (NTFS) but differ on case-sensitive ones + /// (POSIX-style); both spellings are listed in our NuGet targets, so this + /// helper accepts both as well. /// /// A for the manifest. Check before using. public static FileInfo FindManifest(string directory) diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/PathSafety.cs b/src/winapp-CLI/WinApp.Cli/Helpers/PathSafety.cs index 8a4f1eda..43c2c7e6 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/PathSafety.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/PathSafety.cs @@ -235,4 +235,54 @@ public static void AtomicWriteAllText(string path, string contents, System.Text. throw; } } + + // Async variant of . Same staging / + // flush-to-disk / rename semantics, but the write itself is async so + // callers in the workspace setup pipeline don't block on disk IO. + // Supports cancellation while staging (cleanup still runs). + public static async Task AtomicWriteAllTextAsync( + string path, + string contents, + System.Text.Encoding encoding, + CancellationToken cancellationToken = default) + { + var dir = Path.GetDirectoryName(path); + if (string.IsNullOrEmpty(dir)) + { + dir = Directory.GetCurrentDirectory(); + } + var tmp = Path.Combine(dir, Path.GetFileName(path) + ".tmp-" + Guid.NewGuid().ToString("N")); + try + { + await using (var fs = new FileStream( + tmp, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 4096, + useAsync: true)) + await using (var sw = new StreamWriter(fs, encoding)) + { + await sw.WriteAsync(contents.AsMemory(), cancellationToken); + await sw.FlushAsync(cancellationToken); + fs.Flush(flushToDisk: true); + } + File.Move(tmp, path, overwrite: true); + } + catch + { + try + { + if (File.Exists(tmp)) + { + File.Delete(tmp); + } + } + catch + { + // Best-effort cleanup; surface original error. + } + throw; + } + } } diff --git a/src/winapp-CLI/WinApp.Cli/Services/MsixService.cs b/src/winapp-CLI/WinApp.Cli/Services/MsixService.cs index e76b3892..73543ecc 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/MsixService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/MsixService.cs @@ -815,23 +815,18 @@ internal static bool IsRuntimeToolExecutable(string fileName) } /// - /// Checks a single directory for a manifest file (Package.appxmanifest or appxmanifest.xml). + /// Checks a single directory for a manifest file. Delegates to + /// so the probe order is + /// driven by one source of truth — keeping winapp run / + /// create-debug-identity in lock-step with every other site + /// that locates manifests (cert generate, the build NuGet + /// targets, etc.). Returns when no manifest + /// file is present so callers can branch on "found vs. not". /// internal static FileInfo? FindManifestInDirectory(DirectoryInfo directory) { - var packageManifest = new FileInfo(Path.Combine(directory.FullName, "Package.appxmanifest")); - if (packageManifest.Exists) - { - return packageManifest; - } - - var appxManifest = new FileInfo(Path.Combine(directory.FullName, "appxmanifest.xml")); - if (appxManifest.Exists) - { - return appxManifest; - } - - return null; + var found = ManifestHelper.FindManifest(directory.FullName); + return found.Exists ? found : null; } /// diff --git a/src/winapp-CLI/WinApp.Cli/Services/WinmdsLockfileService.cs b/src/winapp-CLI/WinApp.Cli/Services/WinmdsLockfileService.cs index c9720397..493ca31c 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/WinmdsLockfileService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/WinmdsLockfileService.cs @@ -9,7 +9,7 @@ namespace WinApp.Cli.Services; -// UTF-8 (no BOM) indented JSON, LF endings. Atomic writes via tmp + File.Move. +// UTF-8 (no BOM) indented JSON, LF endings. Atomic writes via PathSafety.AtomicWriteAllTextAsync. internal sealed class WinmdsLockfileService(ILogger logger) : IWinmdsLockfileService { public const string LockfileName = "winmds.lock.json"; @@ -41,7 +41,6 @@ public async Task WriteAsync( string? yamlPackagesHash = null, CancellationToken cancellationToken = default) { - string? tempPath = null; try { var path = GetLockfilePath(winappDir); @@ -58,18 +57,15 @@ public async Task WriteAsync( winappDir.Create(); var lockfile = BuildLockfile(usedVersions, discoveredWinmds, nugetCacheDir, yamlPackagesHash); - - // Atomic write via tmp + rename; guid suffix avoids concurrent - // writers colliding on staging. - tempPath = $"{path.FullName}.tmp.{Guid.NewGuid():N}"; var json = JsonSerializer.Serialize(lockfile, WinmdsLockfileJsonContext.Default.WinmdsLockfile); - await File.WriteAllTextAsync( - tempPath, + + // Atomic write via the shared PathSafety helper — single source + // of truth for staging + fsync + rename semantics. + await PathSafety.AtomicWriteAllTextAsync( + path.FullName, json + "\n", new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), cancellationToken); - File.Move(tempPath, path.FullName, overwrite: true); - tempPath = null; logger.LogDebug( "Wrote winmds lockfile ({PackageCount} packages, {WinmdCount} winmds) → {LockfilePath}", @@ -80,15 +76,6 @@ await File.WriteAllTextAsync( // Lockfile is an optimization, not a correctness requirement. logger.LogDebug(ex, "Failed to write winmds lockfile (continuing without)"); } - finally - { - // Clean up staging if Move never ran. - if (tempPath is not null) - { - try { File.Delete(tempPath); } - catch { /* ignore — leaked tmp file is harmless */ } - } - } } public async Task TryReadAsync( diff --git a/src/winapp-NuGet/build/Microsoft.Windows.SDK.BuildTools.WinApp.targets b/src/winapp-NuGet/build/Microsoft.Windows.SDK.BuildTools.WinApp.targets index 6c68ca58..a8756f2d 100644 --- a/src/winapp-NuGet/build/Microsoft.Windows.SDK.BuildTools.WinApp.targets +++ b/src/winapp-NuGet/build/Microsoft.Windows.SDK.BuildTools.WinApp.targets @@ -85,19 +85,15 @@ Windows TFMs must build cleanly with no winapp activity. We prefer the explicitly-set $(TargetPlatformIdentifier) when present and fall back to deriving it from $(TargetFramework). - - A manifest exists at $(WinAppManifestPath). After the auto-detection - block above runs, this property points at the first existing manifest - across all supported output- and project-directory locations, OR at - whatever path the consumer set explicitly. Frameworks like MAUI - generate the manifest into $(OutputPath) based on platform / msbuild - props, so we deliberately accept output-dir paths here — without that, - MAUI head apps consuming this package transitively would never have - the gate activate. The other discriminators above (WindowsPackageType, - OutputType, TPI) already prevent activation in projects that aren't - intended to be packaged Windows apps, so a stale output-dir manifest - from a prior build only matters in a project that is otherwise still - a packaged Windows app — in which case re-activating the gate is the - correct behavior. + - $(WinAppManifestPath) is non-empty AND exists. Manifest existence + IS checked here at parse time so that build-time outputs (.appxrecipe, + manifest promotion, Content copy) get the gate right when the + manifest is already on disk. For build-time-generated manifests + (e.g. MAUI generates into $(OutputPath), or the MSIX SDK rewrites + promoted manifests during Build), the parse-time check evaluates + false; `_WinAppResolveManifestPath` re-evaluates this gate after + Build runs so `dotnet run` / `_WinAppValidateRunSupport` / + `_WinAppBuildRunArgs` see the live answer. ============================================================================ --> @@ -112,11 +108,21 @@ - <_WinAppRunSupportActive Condition=" + + <_WinAppRunSupportShapeMatches Condition=" '$(EnableWinAppRunSupport)' == 'true' And '$(WindowsPackageType)' != 'None' And '$(OutputType)' != 'Library' - And '$(_WinAppEffectiveTargetPlatformIdentifier)' == 'windows' + And '$(_WinAppEffectiveTargetPlatformIdentifier)' == 'windows'">true + <_WinAppRunSupportShapeMatches Condition="'$(_WinAppRunSupportShapeMatches)' == ''">false + + <_WinAppRunSupportActive Condition=" + '$(_WinAppRunSupportShapeMatches)' == 'true' And '$(WinAppManifestPath)' != '' And Exists('$(WinAppManifestPath)')">true <_WinAppRunSupportActive Condition="'$(_WinAppRunSupportActive)' == ''">false @@ -160,20 +166,71 @@ ============================================================================ _WinAppResolveManifestPath Target - When appxmanifest.xml is promoted to AppxManifest, the MSIX SDK generates - AppxManifest.xml in the output directory during build. This target re-resolves - WinAppManifestPath to point to the generated file (which has build metadata, - resolved placeholders, etc.) instead of the raw source file. + Re-resolves $(WinAppManifestPath) and re-evaluates the master gate + $(_WinAppRunSupportActive) AFTER Build has run. The parse-time gate + can only see files that exist on disk before Build executes, which + is wrong for two scenarios this package needs to support: + + 1. appxmanifest.xml is promoted to AppxManifest and the MSIX SDK + generates the final $(OutputPath)AppxManifest.xml during Build + (placeholder resolution, PRI metadata, .appxrecipe stitching). + 2. Frameworks like MAUI generate the manifest into $(OutputPath) + from platform/msbuild props at Build time — the source tree + contains no manifest at all. + + For either case the parse-time auto-detection block can't have set + $(WinAppManifestPath), and even when it did the file may not exist + yet. This target re-runs the output-dir lookup with a fresh + `Exists(...)` and then re-derives $(_WinAppRunSupportActive) so every + downstream target (`_WinAppValidateRunSupport`, `_WinAppBuildRunArgs`, + `_WinAppPrepareRunArguments`, …) sees the live answer. + + Gated only on $(_WinAppRunSupportShapeMatches) — i.e. the project + discriminators (EnableWinAppRunSupport, WindowsPackageType, OutputType, + TPI) — and never on the parse-time master gate. That avoids the + chicken-and-egg where the parse-time gate freezes as false because + Build hasn't generated the manifest yet, which in turn skips this + target, which is the only thing that could unfreeze the gate. ============================================================================ --> - + BeforeTargets="_WinAppValidateRunSupport;_WinAppBuildRunArgs;_WinAppCopyContentToLooseLayout;_WinAppPrepareRunArguments" + Condition="'$(_WinAppRunSupportShapeMatches)' == 'true'"> + + + $(OutputPath)AppxManifest.xml + $(OutputPath)Package.appxmanifest + $(OutputPath)appxmanifest.xml + $(MSBuildProjectDirectory)\AppxManifest.xml + $(MSBuildProjectDirectory)\Package.appxmanifest + $(MSBuildProjectDirectory)\appxmanifest.xml + + + $(OutputPath)AppxManifest.xml - + + + + <_WinAppRunSupportActive Condition=" + '$(_WinAppRunSupportShapeMatches)' == 'true' + And '$(WinAppManifestPath)' != '' + And Exists('$(WinAppManifestPath)')">true + <_WinAppRunSupportActive Condition="'$(_WinAppRunSupportActive)' == ''">false + + + + + + + + + + + + + +"@ + Set-Content -Path (Join-Path $dir "test.csproj") -Value $csproj + + Push-Location $dir + try { + & dotnet build (Join-Path $dir "test.csproj") -nologo 2>&1 | Out-Null + } finally { + Pop-Location + } + + $gateFile = Join-Path $dir "gate-value.txt" + $gateFile | Should -Exist -Because "_TestDumpGateValue must have fired after _WinAppResolveManifestPath" + (Get-Content $gateFile -Raw).Trim() | Should -Be 'true' ` + -Because "_WinAppResolveManifestPath must re-activate the gate once Build has produced the manifest" + } + } } Describe "Microsoft.Windows.SDK.BuildTools.WinApp package layout" -Skip:$script:skip { diff --git a/src/winapp-npm/src/cli-args.ts b/src/winapp-npm/src/cli-args.ts new file mode 100644 index 00000000..b9d6ec56 --- /dev/null +++ b/src/winapp-npm/src/cli-args.ts @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. +// +// Small helpers for argv parsing shared across the npm wrapper's intercepted +// commands (`init`, `restore`, `node generate-bindings`). Keep these in sync +// with the native CLI option / argument shapes in src/winapp-CLI/.../Commands/. +// +// We deliberately do NOT use a full argument-parser library: the wrapper only +// needs to look at a handful of options that affect *where* it should read or +// write workspace files, and we want to forward the user's literal argv to +// the native CLI unchanged. + +import * as path from 'path'; + +/** Options that consume the next argv token as a value (space-separated form). */ +const VALUE_TAKING_OPTIONS = new Set(['--config-dir', '--config', '--setup-sdks']); + +/** `--use-defaults` / `-y` / `--yes`. */ +const USE_DEFAULTS_FLAGS = new Set(['--use-defaults', '--no-prompt', '-y', '--yes']); + +/** + * Resolve the effective workspace directory for the wrapper's local file + * operations (package.json, .winapp/, bindings/ output). Mirrors how the + * native CLI resolves `BaseDirectoryArgument` — first non-flag positional + * argument wins; otherwise the current working directory. + * + * `--config-dir` is intentionally NOT consulted: it only changes where + * winapp.yaml is read/written, not the workspace root. + */ +export function resolveWorkspaceDir(args: readonly string[]): string { + const positional = firstPositional(args); + return positional ? path.resolve(positional) : process.cwd(); +} + +/** + * Return the first non-option positional argument, skipping any token that + * is the value of a value-taking option (e.g. `--config-dir somedir` — `somedir` + * is not a positional). Supports both `--opt value` and `--opt=value` forms. + */ +export function firstPositional(args: readonly string[]): string | undefined { + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg.startsWith('-')) { + // `--opt=value` — entire token is consumed, never a value to skip. + if (arg.includes('=')) continue; + if (VALUE_TAKING_OPTIONS.has(arg)) { + // Skip the next token (the option's value). + i++; + } + continue; + } + return arg; + } + return undefined; +} + +/** Detect `--verbose` / `-v` (anywhere in argv) for opting into noisy codegen logs. */ +export function isVerbose(args: readonly string[]): boolean { + return args.includes('--verbose') || args.includes('-v'); +} + +/** Detect `--quiet` / `-q` (anywhere in argv). Mirrors the native global option. */ +export function isQuiet(args: readonly string[]): boolean { + return args.includes('--quiet') || args.includes('-q'); +} + +/** Detect `--config-only` — init's "skip package installation" mode. */ +export function hasConfigOnly(args: readonly string[]): boolean { + return args.includes('--config-only'); +} + +/** Detect `--use-defaults` / `--no-prompt` / `-y` / `--yes`. */ +export function hasUseDefaults(args: readonly string[]): boolean { + return args.some((a) => USE_DEFAULTS_FLAGS.has(a)); +} + +/** + * Resolve the effective `winapp.yaml` path the native CLI will read, so the + * orchestrator's staleness check (`yaml_packages_hash`) compares against the + * SAME file native used. Mirrors `InitCommand`/`RestoreCommand` semantics: + * + * --config-dir / --config-dir=/winapp.yaml + * (no --config-dir) → /winapp.yaml + * + * Note: `--config-dir` defaults to **current directory**, NOT to the + * `base-directory` positional. So a positional base-dir alone does NOT + * change where the yaml is read from — only `--config-dir` does. Don't + * derive the yaml location from `workspaceDir`. + */ +export function resolveYamlPath(args: readonly string[]): string { + const explicit = extractConfigDir(args); + const configDir = explicit ? path.resolve(explicit) : process.cwd(); + return path.join(configDir, 'winapp.yaml'); +} + +function extractConfigDir(args: readonly string[]): string | undefined { + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === '--config-dir' && i + 1 < args.length) { + return args[i + 1]; + } + if (a.startsWith('--config-dir=')) { + return a.substring('--config-dir='.length); + } + } + return undefined; +} diff --git a/src/winapp-npm/src/cli.ts b/src/winapp-npm/src/cli.ts index 1156cd34..27838673 100644 --- a/src/winapp-npm/src/cli.ts +++ b/src/winapp-npm/src/cli.ts @@ -5,14 +5,11 @@ import { generateCsAddonFiles } from './cs-addon-utils'; import { addElectronDebugIdentity, clearElectronDebugIdentity } from './msix-utils'; import { getWinappCliPath, callWinappCli, callWinappCliCapture, WINAPP_CLI_CALLER_VALUE } from './winapp-cli-utils'; import { askBindingsKind, parseSetupSdksArg } from './jsbindings/init-prompt'; -import { - readJsBindingsConfig, - writeJsBindingsConfig, - defaultJsBindingsConfig, - hasJsBindings, -} from './jsbindings/package-json-config'; +import { hasJsBindings, ensureJsBindingsBlock } from './jsbindings/package-json-config'; import { runJsBindingsPipeline } from './jsbindings/orchestrator'; import { getLockfilePath, LOCKFILE_NAME } from './jsbindings/lockfile-reader'; +import { resolveWorkspaceDir, resolveYamlPath, isVerbose, isQuiet, hasConfigOnly } from './cli-args'; +import { assertSafeWorkspaceFile } from './jsbindings/path-safety'; import { spawn } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; @@ -580,14 +577,15 @@ async function handleGenerateBindings(args: string[]): Promise { console.log('Regenerate JS/TypeScript bindings from package.json + cached winmds'); console.log(''); console.log('This command will:'); - console.log(' 1. Read `winapp.jsBindings` from package.json'); + console.log(' 1. Read (or add a default) `winapp.jsBindings` block in package.json'); console.log(' 2. Read the cached winmd inventory from .winapp/winmds.lock.json'); console.log(' 3. Run dynwinrt-codegen into the configured output directory'); console.log(' 4. Ensure @microsoft/dynwinrt is listed in package.json dependencies'); console.log(''); - console.log('It does NOT re-run the native restore. If you changed `winapp.yaml`'); - console.log('(packages, sdkVersion, etc.) run `winapp restore` first to refresh'); - console.log('the lockfile, then re-run this command.'); + console.log('It does NOT re-run the native restore. If you have never run'); + console.log('`winapp restore` in this workspace (so there is no winmd lockfile yet)'); + console.log('or you changed `winapp.yaml` since the last restore, run `winapp restore`'); + console.log('first, then re-run this command.'); console.log(''); console.log('Options:'); console.log(' --verbose Enable verbose codegen output (default: false)'); @@ -599,35 +597,42 @@ async function handleGenerateBindings(args: string[]): Promise { return; } - const workspaceDir = process.cwd(); + const workspaceDir = resolveWorkspaceDir(args); + const quiet = isQuiet(args); // 1. Must be an npm/Node project — winapp.jsBindings lives in package.json. - if (!fs.existsSync(path.join(workspaceDir, 'package.json'))) { + const pkgJsonPath = path.join(workspaceDir, 'package.json'); + assertSafeWorkspaceFile(workspaceDir, pkgJsonPath, 'package.json'); + if (!fs.existsSync(pkgJsonPath)) { console.error('❌ No package.json found in this directory.'); console.error(' This command only applies to npm/Node projects.'); - console.error(' Run `npm init -y` first, then `winapp init` to configure JS bindings.'); + console.error(' Run `npm init -y` first, then re-run this command.'); process.exit(1); } - // 2. JS bindings must be configured. - if (!hasJsBindings(workspaceDir)) { - console.error('❌ No "winapp.jsBindings" section in package.json.'); - console.error(' Run `winapp init` to configure JS bindings.'); - process.exit(1); - } + // 2. Make sure the `winapp.jsBindings` namespace exists. Running this + // command is itself a strong signal that the user wants bindings — + // refusing on a missing block would force a hand-edit of package.json + // for no real safety benefit. `restore` deliberately does NOT call + // this helper: it stays passive and only acts when the user has + // already declared bindings. + ensureJsBindingsBlock(workspaceDir, { quiet }); // 3. Lockfile from a prior `winapp restore` must be present. (Schema-mismatch // cases get the orchestrator's more detailed message via `lockfileStale`.) - const lockfilePath = getLockfilePath(path.join(workspaceDir, '.winapp')); + const lockfilePath = getLockfilePath(workspaceDir); + assertSafeWorkspaceFile(workspaceDir, lockfilePath, LOCKFILE_NAME); if (!fs.existsSync(lockfilePath)) { console.error(`❌ No .winapp/${LOCKFILE_NAME} found.`); - console.error(' Run `winapp restore` first to fetch SDK packages and build the winmd inventory.'); + console.error(' This file is written by `winapp restore`. If you cloned a fresh repo,'); + console.error(' or upgraded from an older winapp that did not write this lockfile,'); + console.error(' run `winapp restore` once to build the winmd inventory, then re-run this command.'); process.exit(1); } // 4. Hand off to the shared pipeline. Outcomes are translated to ✅ / ❌ /⚠️ // by runJsBindingsOrchestrator. - await runJsBindingsOrchestrator(workspaceDir, options.verbose as boolean); + await runJsBindingsOrchestrator(workspaceDir, isVerbose(args), quiet, resolveYamlPath(args)); } /** @@ -640,7 +645,9 @@ async function handleGenerateBindings(args: string[]): Promise { * code path is identical regardless of the user's choice here. */ async function handleInit(args: string[]): Promise { - const workspaceDir = process.cwd(); + const workspaceDir = resolveWorkspaceDir(args); + const quiet = isQuiet(args); + const configOnly = hasConfigOnly(args); // Re-running init on a configured workspace? Infer the choice rather than // re-prompt so we never silently drop the user's prior customizations. @@ -658,12 +665,18 @@ async function handleInit(args: string[]): Promise { logErrorAndExit(err); } - if (outcome.silentReason) { + if (outcome.silentReason && !quiet) { console.log(`ℹ️ ${outcome.silentReason}`); } - // Native init always runs with the user's literal argv (no flag injection - // and no JS-bindings-aware overrides). + // Native init runs with the user's literal argv (no flag injection, no + // JS-bindings-aware overrides). We deliberately do NOT change the child's + // cwd: native `init` resolves `base-directory` / `--config-dir` against + // its own cwd, so changing it would double-resolve any relative path + // (`winapp init subdir` → spawn cwd=/foo/subdir + arg `subdir` + // → native lands in /foo/subdir/subdir). Wrapper-side `workspaceDir` is + // an absolute path we only use for our OWN bookkeeping (package.json + // read/write, codegen output, prompts). await callWinappCli(['init', ...args], { exitOnError: true }); // User opted out — nothing more to do. @@ -672,35 +685,49 @@ async function handleInit(args: string[]): Promise { } // Persist the default jsBindings block to package.json so subsequent - // `winapp restore` runs pick it up. Skip when package.json is missing so - // we don't fail an init that already succeeded — surface a clear hint. + // `winapp restore` / `winapp node generate-bindings` runs pick it up. Skip + // when package.json is missing so we don't fail an init that already + // succeeded — surface a clear hint. try { - if (!fs.existsSync(path.join(workspaceDir, 'package.json'))) { - console.warn( - '⚠️ package.json not found in this workspace. ' + - 'Run `npm init -y` (or equivalent) and then `npx winapp restore` to enable JS bindings.' - ); + const pkgJsonPath = path.join(workspaceDir, 'package.json'); + assertSafeWorkspaceFile(workspaceDir, pkgJsonPath, 'package.json'); + if (!fs.existsSync(pkgJsonPath)) { + if (!quiet) { + console.warn( + '⚠️ package.json not found in this workspace. ' + + 'Run `npm init -y` (or equivalent) and then `npx winapp node generate-bindings` to enable JS bindings.' + ); + } return; } - const refreshed = readJsBindingsConfig(workspaceDir); - const wantsOverwrite = outcome.overwriteExistingConfig === true; - if (!refreshed.jsBindings) { - writeJsBindingsConfig(workspaceDir, defaultJsBindingsConfig()); - console.log( - 'ℹ️ Added "winapp.jsBindings" to package.json. ' + 'Edit it to customize package scope, extraTypes, etc.' - ); - } else if (wantsOverwrite) { - writeJsBindingsConfig(workspaceDir, defaultJsBindingsConfig()); - console.log('ℹ️ Reset "winapp.jsBindings" in package.json to defaults.'); - } + ensureJsBindingsBlock(workspaceDir, { + reset: outcome.overwriteExistingConfig === true, + quiet, + }); } catch (err) { console.error(`Failed to update package.json: ${(err as Error).message}`); process.exit(1); } - // Trigger a restore now (writes the winmd lockfile) and run the orchestrator. - await callWinappCli(['restore'], { exitOnError: true }); - await runJsBindingsOrchestrator(workspaceDir, isVerbose(args)); + // --config-only skips package installation in the native CLI, so no lockfile + // gets written and the orchestrator would fail with `lockfileMissing`. Honor + // the user's intent and stop here — they can run `winapp restore` later. + if (configOnly) { + if (!quiet) { + console.log( + 'ℹ️ --config-only requested; JS bindings codegen deferred. ' + + 'Run `npx winapp restore` (or `npx winapp node generate-bindings` after a restore) to generate.' + ); + } + return; + } + + // Native `winapp init` already invoked WorkspaceSetupService, which wrote + // .winapp/winmds.lock.json as part of Step 5 (winmd discovery). The previous + // implementation re-ran `winapp restore` here defensively, but that doubled + // the cost of init and ignored `--config-only`. Hand straight off to the + // orchestrator instead. + await runJsBindingsOrchestrator(workspaceDir, isVerbose(args), quiet, resolveYamlPath(args)); } /** @@ -708,28 +735,34 @@ async function handleInit(args: string[]): Promise { * dynwinrt-codegen iff package.json declares `winapp.jsBindings`. */ async function handleRestore(args: string[]): Promise { - const workspaceDir = process.cwd(); + const workspaceDir = resolveWorkspaceDir(args); + const quiet = isQuiet(args); + // See handleInit: do NOT set child cwd. Native `restore` resolves + // `base-directory` / `--config-dir` relative to its own cwd; forwarding + // a relative positional from a re-rooted shell would double-resolve. await callWinappCli(['restore', ...args], { exitOnError: true }); if (!hasJsBindings(workspaceDir)) { return; } - await runJsBindingsOrchestrator(workspaceDir, isVerbose(args)); -} - -/** Detect `--verbose` / `-v` (anywhere in argv) for opting into noisy codegen logs. */ -function isVerbose(args: string[]): boolean { - return args.includes('--verbose') || args.includes('-v'); + await runJsBindingsOrchestrator(workspaceDir, isVerbose(args), quiet, resolveYamlPath(args)); } /** Runs the JS bindings pipeline and translates outcomes into exit codes. */ -async function runJsBindingsOrchestrator(workspaceDir: string, verbose: boolean = false): Promise { +async function runJsBindingsOrchestrator( + workspaceDir: string, + verbose: boolean = false, + quiet: boolean = false, + yamlPath?: string +): Promise { try { - const result = await runJsBindingsPipeline({ workspaceDir, verbose }); + const result = await runJsBindingsPipeline({ workspaceDir, verbose, quiet, yamlPath }); switch (result.outcome) { case 'completed': - console.log(`✅ ${result.message}`); + if (!quiet) { + console.log(`✅ ${result.message}`); + } return; case 'noJsBindings': // Silent — caller already vetted that jsBindings is configured. @@ -740,6 +773,7 @@ async function runJsBindingsOrchestrator(workspaceDir: string, verbose: boolean process.exit(1); break; case 'noWinmdsToEmit': + // Warning surfaces even with --quiet so users see actionable signals. console.warn(`⚠️ ${result.message}`); return; } diff --git a/src/winapp-npm/src/jsbindings/codegen-runner.ts b/src/winapp-npm/src/jsbindings/codegen-runner.ts index 0ad9ea9e..33146ebe 100644 --- a/src/winapp-npm/src/jsbindings/codegen-runner.ts +++ b/src/winapp-npm/src/jsbindings/codegen-runner.ts @@ -23,6 +23,7 @@ import * as crypto from 'crypto'; import { spawn } from 'child_process'; import { JsBindingsConfig } from './package-json-config'; import { JsBindingsExtraType } from './additional-winmds'; +import { assertSafeWorkspaceOutputDir, isNetworkPath, hasReparsePointOnPath } from './path-safety'; // Marker written into the output dir after a successful run; its presence // authorises the next run to wipe the dir. @@ -101,71 +102,12 @@ export async function runCodegen(inputs: CodegenInputs): Promise // ---- output dir resolution + safety --------------------------------------- export function resolveOutputDir(workspaceDir: string, output: string): string { + // Single source of truth for "this directory will be wiped before each + // codegen run" safety policy: must be UNC-free, strictly inside the + // workspace, and reparse-point-free along the entire path. Mirrors + // PathSafety guards on the native side. const out = output && output.trim() ? output : 'bindings'; - const resolved = path.isAbsolute(out) ? path.resolve(out) : path.resolve(workspaceDir, out); - const workspaceFull = path.resolve(workspaceDir).replace(/[\\/]+$/, ''); - const prefix = workspaceFull + path.sep; - const inside = resolved.length > prefix.length && resolved.toLowerCase().startsWith(prefix.toLowerCase()); - if (!inside) { - throw new Error( - `jsBindings.output ('${output}') resolves to '${resolved}' which is outside the workspace ('${workspaceFull}'). ` + - 'The output directory is wiped before each codegen run, so it must be a path strictly ' + - "inside the workspace. Use a relative path like 'bindings' or an absolute path " + - 'that descends from the workspace root.' - ); - } - - // Reject reparse-point ancestors so the recursive delete can't follow a - // junction outside the workspace. - let probe = resolved; - for (;;) { - if (fs.existsSync(probe)) { - try { - const stat = fs.lstatSync(probe); - if (stat.isSymbolicLink()) { - throw new Error( - `jsBindings.output ('${output}') resolves through a reparse point at '${probe}'. ` + - 'Reparse points (symlinks / junctions) are rejected because they could redirect ' + - 'the output wipe outside the workspace. Move the output to a regular subdirectory ' + - 'of the workspace.' - ); - } - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code !== 'ENOENT' && code !== 'ENOTDIR') { - throw err; - } - } - } - const trimmed = probe.replace(/[\\/]+$/, ''); - if (trimmed.toLowerCase() === workspaceFull.toLowerCase()) { - break; - } - const parent = path.dirname(probe); - if (parent === probe || parent.length < workspaceFull.length) { - break; - } - probe = parent; - // Stop walking once we've reached or passed the workspace root. - if (probe.replace(/[\\/]+$/, '').toLowerCase() === workspaceFull.toLowerCase()) { - // Check the workspace itself once. - try { - if (fs.existsSync(probe) && fs.lstatSync(probe).isSymbolicLink()) { - throw new Error( - `Workspace directory '${probe}' is a reparse point. Refusing to use it as a codegen boundary.` - ); - } - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code !== 'ENOENT' && code !== 'ENOTDIR') { - throw err; - } - } - break; - } - } - - return resolved; + return assertSafeWorkspaceOutputDir(workspaceDir, out, 'jsBindings.output'); } /** Throws when outputDir contains files we didn't generate. Empty / missing OK. */ @@ -448,31 +390,42 @@ interface CodegenInvocation { prefixArgs: string[]; } -// Locate dynwinrt-codegen by walking up from the wrapper install dir looking -// for node_modules/@microsoft/dynwinrt-codegen. Workspace-local installs are -// not trusted (a cloned repo could substitute a malicious codegen). +// Locate dynwinrt-codegen. Preferred resolution order: +// 1. `require.resolve('@microsoft/dynwinrt-codegen/package.json')` anchored +// at the wrapper directory — this is the canonical Node module-resolver, +// so it works with hoisted node_modules (npm / yarn-classic), +// pnpm-default's symlinked layout, and yarn-Berry PnP. +// 2. Physical node_modules walk — defensive fallback for the rare case +// where the wrapper is loaded via something that breaks +// `require.resolve` (e.g., custom bundler with frozen paths). +// +// Workspace-local installs are still preferred (a wrapper installed under +// the user's workspace co-locates the codegen there), and we only trust +// `cli.js` at a real on-disk path that we can lstat — so PnP's virtual +// `.zip!/` paths are converted to an unzipped on-disk location by Node +// itself before we read them. export function resolveCodegenInvocation(): CodegenInvocation { const wrapperDir = tryGetWrapperDir(); const arch = resolveArchSubdir(); + const pkgDirs = resolveCodegenPackageDirs(wrapperDir); let lastChecked: string | null = null; - for (let probe: string | null = wrapperDir; probe; probe = parentOrNull(probe)) { - const pkgDir = path.join(probe, 'node_modules', '@microsoft', 'dynwinrt-codegen'); - if (!fs.existsSync(pkgDir)) { - continue; - } - + for (const pkgDir of pkgDirs) { // Priority 1: pre-built .exe (no Node startup needed). const exePath = path.join(pkgDir, 'bin', arch, 'dynwinrt-codegen.exe'); if (fs.existsSync(exePath)) { return { executable: exePath, prefixArgs: [] }; } - // Priority 2: cli.js via node.exe — defensive fallback. Reject .bat/.cmd - // because they dispatch through cmd.exe and would re-parse user-derived args. + // Priority 2: cli.js via node — defensive fallback. Prefer the current + // wrapper's own interpreter (`process.execPath`) over PATH lookup so a + // poisoned PATH (UNC entry, reparse junction, attacker-controlled dir) + // can't substitute a hostile node.exe for cli.js execution. We still + // walk PATH as a last resort for the unusual case where the wrapper is + // launched from a non-node interpreter (e.g. an `.exe` shim). const cliJs = path.join(pkgDir, 'cli.js'); if (fs.existsSync(cliJs)) { - const nodePath = resolveNativeNodeOnPath(); + const nodePath = resolveTrustedNodeInterpreter(); if (!nodePath) { throw new Error( `The codegen at '${cliJs}' requires a native Node.js executable (node.exe) on PATH. ` + @@ -493,21 +446,75 @@ export function resolveCodegenInvocation(): CodegenInvocation { `(expected 'bin/${arch}/dynwinrt-codegen.exe' or 'cli.js'). ` + 'The npm package may be corrupt; reinstall it.\n\n' : `Searched ${CODEGEN_PACKAGE_NAME} from the wrapper install${wrapperHint} — ` + - 'no node_modules/@microsoft/dynwinrt-codegen found.\n\n'; + `no ${CODEGEN_PACKAGE_NAME} resolvable via Node module resolution.\n\n`; throw new Error( partialHint + - 'To enable JS bindings, install via npm or yarn classic:\n' + + 'To enable JS bindings, install via your package manager:\n' + ' npm i -D @microsoft/winappcli\n' + `(bundles ${CODEGEN_PACKAGE_NAME} as a transitive dependency.)\n\n` + - 'Non-hoisting layouts (pnpm default, yarn-Berry PnP) are not supported: the\n' + - 'codegen binary must live next to the winapp launcher so winapp can verify\n' + - "it ships the binary it's spawning. For pnpm, set 'node-linker=hoisted' in\n" + - ".npmrc; for yarn-Berry, set 'nodeLinker: node-modules' in .yarnrc.yml.\n\n" + + 'pnpm and yarn (classic / Berry / PnP) are supported via Node module resolution.\n\n' + 'See https://github.com/microsoft/WinAppCli#electron--nodejs for setup details.' ); } +/** + * Build the list of candidate `@microsoft/dynwinrt-codegen` package + * directories. Iterates lazily so we stop as soon as the first match has + * been fully validated by the caller. + * + * Order: + * * Anchored `require.resolve` from the wrapper dir. Honors all linkers + * (hoisted, isolated, PnP) because it goes through Node's own resolver. + * * Physical `node_modules/@microsoft/dynwinrt-codegen` walk from the + * wrapper dir upward — same as the legacy behaviour, kept as a safety + * net for bundled / patched layouts where `require.resolve` is stubbed. + */ +function* resolveCodegenPackageDirs(wrapperDir: string | null): Generator { + const seen = new Set(); + + const yieldUnique = function* (dir: string | null): Generator { + if (!dir) return; + const key = dir.toLowerCase(); + if (seen.has(key)) return; + seen.add(key); + yield dir; + }; + + // Strategy 1: Node module resolution (PnP / pnpm / npm / yarn-classic). + yield* yieldUnique(resolveViaRequireResolve(wrapperDir)); + + // Strategy 2: physical node_modules walk from the wrapper dir upward. + for (let probe: string | null = wrapperDir; probe; probe = parentOrNull(probe)) { + const pkgDir = path.join(probe, 'node_modules', '@microsoft', 'dynwinrt-codegen'); + if (fs.existsSync(pkgDir)) { + yield* yieldUnique(pkgDir); + } + } +} + +function resolveViaRequireResolve(wrapperDir: string | null): string | null { + const searchPaths: string[] = []; + if (wrapperDir) searchPaths.push(wrapperDir); + // Always include the wrapper module's own directory so global installs + // (`npm i -g @microsoft/winappcli`) still resolve the bundled codegen. + searchPaths.push(__dirname); + + try { + const pkgJson = require.resolve(`${CODEGEN_PACKAGE_NAME}/package.json`, { paths: searchPaths }); + // pkgJson should be a real on-disk file path. PnP can return virtual + // paths inside `.zip!/`; reject those by requiring the parent dir to + // exist on disk. + const pkgDir = path.dirname(pkgJson); + if (fs.existsSync(pkgDir)) { + return pkgDir; + } + } catch { + // Not resolvable from any anchor — fall through to the physical walk. + } + return null; +} + function parentOrNull(dir: string): string | null { const parent = path.dirname(dir); return parent === dir ? null : parent; @@ -546,8 +553,61 @@ function resolveArchSubdir(): string { return os.arch() === 'arm64' ? 'arm64' : 'x64'; } +// Locate a trusted node.exe to run `cli.js`. Priority: +// 1. `process.execPath` — the interpreter currently executing this wrapper. +// That's the same node we just used to load this very module, so its +// provenance is implicitly trusted (the npm package manager picked it). +// We still verify it is a `.exe` / `.com` and that no segment of the +// path is a reparse point / UNC — defends against `npm` being launched +// via a junction into a hostile share. +// 2. PATH walk — fallback for the rare case where the wrapper is bundled +// into an `.exe` shim and `process.execPath` doesn't point at a Node +// interpreter. Each PATH candidate must pass the same safety gate. +// +// Rejects `.bat` / `.cmd` because those dispatch through `cmd.exe` and would +// re-parse user-derived args. +function resolveTrustedNodeInterpreter(): string | null { + const execPath = process.execPath; + if (execPath && isAcceptableNodeExe(execPath)) { + return execPath; + } + return resolveNativeNodeOnPath(); +} + +function isAcceptableNodeExe(candidate: string): boolean { + if (!candidate) { + return false; + } + // UNC / network paths: reject. Workspace-style reparse-point walk needs a + // boundary; for arbitrary system paths (`C:\Program Files\nodejs\…`) we + // anchor on the candidate's drive root so the entire path is scanned for + // reparse junctions. + if (isNetworkPath(candidate)) { + return false; + } + const ext = path.extname(candidate).toLowerCase(); + if (ext !== '.exe' && ext !== '.com') { + return false; + } + let resolved: string; + try { + resolved = path.resolve(candidate); + } catch { + return false; + } + if (!fs.existsSync(resolved)) { + return false; + } + const driveRoot = path.parse(resolved).root.replace(/[\\/]+$/, ''); + if (driveRoot && hasReparsePointOnPath(resolved, driveRoot)) { + return false; + } + return true; +} + // Walk PATH looking for node.exe / node.com. Rejects relative PATH entries, -// drops CWD-equivalent entries, and only accepts native .exe/.com (no .bat/.cmd). +// drops CWD-equivalent entries, refuses UNC / reparse-backed candidates, +// and only accepts native .exe/.com (no .bat/.cmd). function resolveNativeNodeOnPath(): string | null { const command = 'node'; const pathEnv = process.env.PATH ?? ''; @@ -565,6 +625,9 @@ function resolveNativeNodeOnPath(): string | null { if (!dir || dir === '.' || !path.isAbsolute(dir)) { continue; } + if (isNetworkPath(dir)) { + continue; + } let resolvedDir: string; try { resolvedDir = path.resolve(dir); @@ -576,16 +639,13 @@ function resolveNativeNodeOnPath(): string | null { } for (const ext of ['.exe', '.com']) { const candidate = path.join(resolvedDir, command + ext); - if (fs.existsSync(candidate)) { + if (fs.existsSync(candidate) && isAcceptableNodeExe(candidate)) { return candidate; } } const bare = path.join(resolvedDir, command); - if (fs.existsSync(bare)) { - const ext = path.extname(bare).toLowerCase(); - if (ext === '.exe' || ext === '.com') { - return bare; - } + if (fs.existsSync(bare) && isAcceptableNodeExe(bare)) { + return bare; } } return null; diff --git a/src/winapp-npm/src/jsbindings/lockfile-reader.ts b/src/winapp-npm/src/jsbindings/lockfile-reader.ts index 487b2e87..3ab20b21 100644 --- a/src/winapp-npm/src/jsbindings/lockfile-reader.ts +++ b/src/winapp-npm/src/jsbindings/lockfile-reader.ts @@ -12,6 +12,7 @@ import * as fs from 'fs'; import * as path from 'path'; +import { assertSafeWorkspaceFile, isNetworkPath, hasReparsePointOnPath } from './path-safety'; // Schema bumped to 3 when the npm wrapper took over JS bindings: schema 2 // embedded a `category` field that is now strictly an npm-side computation. @@ -32,8 +33,12 @@ export interface WinmdsLockfile { packages: WinmdsLockfilePackage[]; } -export function getLockfilePath(winappDir: string): string { - return path.join(winappDir, LOCKFILE_NAME); +/** + * Lockfile lives at `/.winapp/winmds.lock.json`. Exposed so + * `cli.ts` can `existsSync`-probe without re-reading or parsing the file. + */ +export function getLockfilePath(workspaceDir: string): string { + return path.join(workspaceDir, '.winapp', LOCKFILE_NAME); } export interface ReadLockfileResult { @@ -42,12 +47,28 @@ export interface ReadLockfileResult { reason?: string; } -// Reads + parses the lockfile, validating the schema version. Returns null -// when the file is missing, unreadable, malformed, or schema-mismatched — -// callers should treat any null as "trigger live discovery / ask the user -// to rerun restore". -export function tryReadLockfile(winappDir: string): ReadLockfileResult { - const filePath = getLockfilePath(winappDir); +/** + * Read + parse the workspace's lockfile, validating schema version and the + * containment of every `winmds[]` path entry against the recorded + * `nuget_cache_dir`. Returns null when the file is missing, unreadable, + * malformed, or schema-mismatched — callers should treat any null as "trigger + * live discovery / ask the user to rerun restore". + * + * The path-safety guard is wired in so a hostile `.winapp/` (e.g. a junction + * pointing at another user's profile) is refused before we even open the + * lockfile. Matches the native side's `IsLockfilePathUnsafe()`. + */ +export function tryReadLockfile(workspaceDir: string): ReadLockfileResult { + const winappDir = path.join(workspaceDir, '.winapp'); + const filePath = getLockfilePath(workspaceDir); + + // Refuse to follow reparse points / UNC ancestors BEFORE probing existence. + try { + assertSafeWorkspaceFile(workspaceDir, filePath, LOCKFILE_NAME); + } catch (err) { + return { lockfile: null, reason: (err as Error).message }; + } + if (!fs.existsSync(filePath)) { return { lockfile: null }; } @@ -80,8 +101,10 @@ export function tryReadLockfile(winappDir: string): ReadLockfileResult { } const obj = parsed as Record; - // Native writes `schemaVersion`; tolerate the legacy `schema` key just in case. - const schemaRaw = obj.schemaVersion ?? obj.schema; + // Native writes JsonKnownNamingPolicy.SnakeCaseLower (see WinmdsLockfile.cs), + // so the on-disk keys are snake_case. We tolerate camelCase as a legacy/test + // fallback for any older lockfiles or hand-authored test fixtures. + const schemaRaw = obj.schema ?? obj.schemaVersion; const schemaVersion = typeof schemaRaw === 'number' ? schemaRaw : typeof schemaRaw === 'string' ? Number(schemaRaw) : Number.NaN; @@ -102,6 +125,32 @@ export function tryReadLockfile(winappDir: string): ReadLockfileResult { }; } + const generatedAt = + typeof obj.generated_at === 'string' + ? obj.generated_at + : typeof obj.generatedAt === 'string' + ? obj.generatedAt + : undefined; + const nugetCacheDir = + typeof obj.nuget_cache_dir === 'string' + ? obj.nuget_cache_dir + : typeof obj.nugetCacheDir === 'string' + ? obj.nugetCacheDir + : undefined; + const yamlPackagesHash = + typeof obj.yaml_packages_hash === 'string' + ? obj.yaml_packages_hash + : typeof obj.yamlPackagesHash === 'string' + ? obj.yamlPackagesHash + : undefined; + + // Each winmd path must be a real file under the recorded NuGet cache — + // anything else (UNC, reparse-backed, or escaped via `..`) gets dropped + // with a logged reason. Without `nuget_cache_dir` we have no boundary, + // so we only enforce the UNC/empty checks and rely on the codegen to + // surface absolute-path requirements. + const cacheBoundary = nugetCacheDir ? path.resolve(nugetCacheDir).replace(/[\\/]+$/, '') : null; + const droppedPaths: string[] = []; const packages: WinmdsLockfilePackage[] = []; for (const entry of packagesRaw) { if (!entry || typeof entry !== 'object') { @@ -113,15 +162,59 @@ export function tryReadLockfile(winappDir: string): ReadLockfileResult { if (!name || !version) { continue; } - const winmdsArr = Array.isArray(e.winmds) ? e.winmds.filter((w): w is string => typeof w === 'string') : []; + const rawWinmds = Array.isArray(e.winmds) ? e.winmds.filter((w): w is string => typeof w === 'string') : []; + const winmdsArr: string[] = []; + for (const w of rawWinmds) { + if (!w || !w.trim() || isNetworkPath(w)) { + droppedPaths.push(w); + continue; + } + if (cacheBoundary) { + const resolved = path.resolve(w); + const prefix = cacheBoundary + path.sep; + const inside = resolved.length >= prefix.length && resolved.toLowerCase().startsWith(prefix.toLowerCase()); + if (!inside) { + droppedPaths.push(w); + continue; + } + if (hasReparsePointOnPath(resolved, cacheBoundary)) { + droppedPaths.push(w); + continue; + } + } + winmdsArr.push(w); + } packages.push({ name, version, winmds: winmdsArr }); } + if (droppedPaths.length > 0) { + // Surface the count + first few paths so a corrupted / tampered lockfile + // produces an actionable signal rather than silently emitting nothing. + const head = droppedPaths.slice(0, 3).join(', '); + const suffix = droppedPaths.length > 3 ? ` (+${droppedPaths.length - 3} more)` : ''; + return { + lockfile: null, + reason: + `Lockfile ${filePath} contains ${droppedPaths.length} winmd path(s) outside the recorded ` + + `nuget_cache_dir or via UNC / reparse points: ${head}${suffix}. ` + + 'Re-run `winapp restore` to regenerate from a trusted NuGet cache.', + }; + } + + // Verify .winapp/ itself wasn't swapped between the existsSync probe and + // returning — best-effort secondary check; if this fails we report the same + // safety reason rather than a half-loaded lockfile. + try { + assertSafeWorkspaceFile(workspaceDir, winappDir, '.winapp'); + } catch (err) { + return { lockfile: null, reason: (err as Error).message }; + } + const lockfile: WinmdsLockfile = { schemaVersion, - generatedAt: typeof obj.generatedAt === 'string' ? obj.generatedAt : undefined, - nugetCacheDir: typeof obj.nugetCacheDir === 'string' ? obj.nugetCacheDir : undefined, - yamlPackagesHash: typeof obj.yamlPackagesHash === 'string' ? obj.yamlPackagesHash : undefined, + generatedAt, + nugetCacheDir, + yamlPackagesHash, packages, }; return { lockfile }; diff --git a/src/winapp-npm/src/jsbindings/orchestrator.ts b/src/winapp-npm/src/jsbindings/orchestrator.ts index 8ac51f6c..fe0c3ebe 100644 --- a/src/winapp-npm/src/jsbindings/orchestrator.ts +++ b/src/winapp-npm/src/jsbindings/orchestrator.ts @@ -19,12 +19,13 @@ import * as path from 'path'; import { readJsBindingsConfig, JsBindingsConfig } from './package-json-config'; import { tryReadLockfile } from './lockfile-reader'; -import { partitionByPackageCategory } from './winmd-policy'; +import { partitionPackageWinmds } from './winmd-policy'; import { resolveAdditionalWinmds } from './additional-winmds'; import { runCodegen } from './codegen-runner'; import { ensureRuntimeDependency, formatRuntimeDependencyHint, getDynWinrtVersionPin } from './runtime-dep-injector'; import { detectPackageManager } from './package-manager-detector'; import { startSpinner, Spinner } from './spinner'; +import { computeYamlPackagesHash, readWinappYamlPackages } from './yaml-packages-hash'; export const RUNTIME_PACKAGE_NAME = '@microsoft/dynwinrt'; @@ -40,12 +41,26 @@ export interface OrchestratorResult { export interface OrchestratorOptions { workspaceDir: string; + /** + * Explicit `winapp.yaml` path the native CLI used (resolved from `--config-dir` + * by the caller via {@link resolveYamlPath}). Defaults to + * `/winapp.yaml` for backward-compat; pass it explicitly + * whenever the user supplied `--config-dir` so the staleness check + * compares against the same file native hashed into the lockfile. + */ + yamlPath?: string; /** Override for the npm wrapper's pinned dynwinrt version (used in tests). */ versionOverride?: string; /** Sink for per-line progress (stdout/stderr from codegen). Defaults to console. */ log?: (line: string) => void; /** Forward to codegen-runner. False (default) suppresses per-file noise. */ verbose?: boolean; + /** + * Suppress all non-essential progress / hint output. Errors and warnings + * still go through `log`; the spinner, `🔨` fallback, and runtime-dep hint + * are skipped. Used by `--quiet` on the wrapper. + */ + quiet?: boolean; } export async function runJsBindingsPipeline(options: OrchestratorOptions): Promise { @@ -68,18 +83,43 @@ export async function runJsBindingsPipeline(options: OrchestratorOptions): Promi } const config = pkgResult.jsBindings; - // 2. Read lockfile. - const winappDir = path.join(workspaceDir, '.winapp'); - const lockResult = tryReadLockfile(winappDir); + // 2. Read lockfile (workspace-scoped: lives at /.winapp/winmds.lock.json). + const lockResult = tryReadLockfile(workspaceDir); if (!lockResult.lockfile) { return { outcome: lockResult.reason?.includes('schema mismatch') ? 'lockfileStale' : 'lockfileMissing', message: - lockResult.reason ?? `No ${path.join(winappDir, 'winmds.lock.json')} found. Run \`winapp restore\` first.`, + lockResult.reason ?? + `No ${path.join(workspaceDir, '.winapp', 'winmds.lock.json')} found. ` + + 'This file is written by `winapp restore`. Run `winapp restore` once ' + + '(or re-run it after upgrading from an older winapp version) to build the ' + + 'winmd inventory, then retry.', }; } const lockfile = lockResult.lockfile; + // 2a. Compare the lockfile's recorded `yaml_packages_hash` against a fresh + // hash of `winapp.yaml`. If the user edited the SDK pins without + // re-running `winapp restore`, the lockfile's winmd inventory is for + // the OLD packages — emitting JS bindings now would generate against + // stale types. Surface as `lockfileStale` so the cli.ts caller prints + // the actionable `winapp restore` hint. + if (lockfile.yamlPackagesHash) { + const currentPackages = readWinappYamlPackages(workspaceDir, options.yamlPath); + if (currentPackages) { + const currentHash = computeYamlPackagesHash(currentPackages); + if (currentHash !== lockfile.yamlPackagesHash) { + return { + outcome: 'lockfileStale', + message: + `winapp.yaml \`packages:\` has changed since the last \`winapp restore\` ` + + `(lockfile hash ${lockfile.yamlPackagesHash.slice(0, 12)}…, current ${currentHash.slice(0, 12)}…). ` + + 'Run `winapp restore` to refresh the winmd inventory before generating bindings.', + }; + } + } + } + // 3. Resolve user-supplied additional winmds (each independently). const userEmit = resolveAdditionalWinmds(config.additionalWinmds, workspaceDir, 'additionalWinmds'); const userRefs = resolveAdditionalWinmds(config.additionalRefs, workspaceDir, 'additionalRefs'); @@ -87,20 +127,15 @@ export async function runJsBindingsPipeline(options: OrchestratorOptions): Promi log(w); } - // 4. Partition NuGet winmds by category. Per-package overrides from config. - const flatWinmds: string[] = []; - for (const pkg of lockfile.packages) { - for (const w of pkg.winmds) { - flatWinmds.push(w); - } - } - const partition = partitionByPackageCategory(flatWinmds, { + // 4. Partition NuGet winmds by category, using the lockfile's per-package + // grouping directly (no path-extraction guesswork). Per-package overrides + // from config are layered on top of the built-in skip / ref-only lists. + const partition = partitionPackageWinmds(lockfile.packages, { overrides: { skip: config.skipPackages, refOnly: config.refOnlyPackages, emit: config.emitPackages, }, - nugetCacheRoot: lockfile.nugetCacheDir, emitScope: config.packages.length > 0 ? config.packages : undefined, }); @@ -126,11 +161,11 @@ export async function runJsBindingsPipeline(options: OrchestratorOptions): Promi `Generating JS bindings from ${emitWinmds.length} winmd${emitWinmds.length === 1 ? '' : 's'}` + (refWinmds.length > 0 ? ` (+${refWinmds.length} ref)` : '') + `...`; - const useSpinner = !options.log && !options.verbose; + const useSpinner = !options.log && !options.verbose && !options.quiet; let spinner: Spinner | null = null; if (useSpinner) { spinner = startSpinner(progressText); - } else { + } else if (!options.quiet) { log(`🔨 ${progressText}`); } @@ -160,8 +195,11 @@ export async function runJsBindingsPipeline(options: OrchestratorOptions): Promi ensureResult.pinnedVersion, pm.installCommand ); - log(hint.message); + if (!options.quiet) { + log(hint.message); + } } catch (err) { + // Warnings always surface, even in --quiet, so users still see real failures. log(`⚠️ Failed to ensure runtime dependency: ${(err as Error).message}`); } } diff --git a/src/winapp-npm/src/jsbindings/package-json-config.ts b/src/winapp-npm/src/jsbindings/package-json-config.ts index 1e83b70b..786f92a6 100644 --- a/src/winapp-npm/src/jsbindings/package-json-config.ts +++ b/src/winapp-npm/src/jsbindings/package-json-config.ts @@ -16,11 +16,8 @@ // (init, restore, package, ...) is identical regardless of whether the // user opted into JS bindings. -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; - import { JsBindingsExtraType } from './additional-winmds'; +import { readPackageJsonDoc, mutatePackageJsonDoc, packageJsonExists } from './package-json-doc'; export interface JsBindingsConfig { // Target language. Currently 'js' (default) or 'py'. @@ -64,39 +61,96 @@ export interface ReadJsBindingsResult { jsBindings: JsBindingsConfig | null; } -const PACKAGE_JSON = 'package.json'; - /** * Read package.json from the workspace and return any * `"winapp": { "jsBindings": {...} }` namespace it declares. * - * Missing file → `{ packageJsonExists: false, jsBindings: null }`. + * Missing file (or unsafe workspace path) → `{ packageJsonExists: false, jsBindings: null }`. * Present file, no `winapp.jsBindings` → `{ packageJsonExists: true, jsBindings: null }`. * Malformed JSON propagates as an exception so callers can surface a clear * error rather than silently treating the workspace as un-configured. */ export function readJsBindingsConfig(workspaceDir: string): ReadJsBindingsResult { - const filePath = path.join(workspaceDir, PACKAGE_JSON); - if (!fs.existsSync(filePath)) { + const doc = readPackageJsonDoc(workspaceDir); + if (!doc) { return { packageJsonExists: false, jsBindings: null }; } - const raw = fs.readFileSync(filePath, 'utf8'); - const parsed = JSON.parse(raw); - const ns = parsed && typeof parsed === 'object' ? parsed.winapp : undefined; - const block = ns && typeof ns === 'object' ? ns.jsBindings : undefined; + const ns = doc.parsed.winapp; + const block = + ns && typeof ns === 'object' && !Array.isArray(ns) ? (ns as Record).jsBindings : undefined; if (!block || typeof block !== 'object') { return { packageJsonExists: true, jsBindings: null }; } return { packageJsonExists: true, jsBindings: coerceConfig(block) }; } -/** Convenience: returns true when package.json declares `winapp.jsBindings`. */ +/** + * Convenience: returns true when package.json declares `winapp.jsBindings`. + * Propagates JSON parse errors (does NOT swallow them) — a malformed + * package.json should fail the command with the actual parse error rather + * than silently skip codegen. Callers should `try` around this if they need + * to handle malformed input gracefully. + */ export function hasJsBindings(workspaceDir: string): boolean { - try { - return readJsBindingsConfig(workspaceDir).jsBindings !== null; - } catch { - return false; + return readJsBindingsConfig(workspaceDir).jsBindings !== null; +} + +/** + * Outcome of {@link ensureJsBindingsBlock}. + * * `added` — namespace was missing; default block written. + * * `reset` — namespace existed but caller asked to overwrite it with defaults. + * * `unchanged` — namespace existed and caller did not request a reset. + */ +export type EnsureJsBindingsOutcome = 'added' | 'reset' | 'unchanged'; + +export interface EnsureJsBindingsOptions { + /** + * When true, overwrite an existing `winapp.jsBindings` block with the + * default config. Use this when the user explicitly opted in again + * (e.g. re-running `winapp init` and answering Yes after previously + * customizing the block) — we never silently overwrite otherwise. + */ + reset?: boolean; + /** Suppress the informational banner printed to stdout. */ + quiet?: boolean; +} + +/** + * Make sure the workspace's package.json declares the + * `winapp.jsBindings` namespace, then return what we did. + * + * Shared by `winapp init` (after a "yes" answer) and + * `winapp node generate-bindings` (so the command works without making + * the user hand-edit JSON before invoking it). NOT called from + * `winapp restore` — restore must remain a passive "respect existing + * declarations" operation and never silently add config the user did + * not request. + * + * Requires package.json to exist; callers should fail with a clear + * "this is not an npm project" error first when it does not. + */ +export function ensureJsBindingsBlock( + workspaceDir: string, + opts: EnsureJsBindingsOptions = {} +): EnsureJsBindingsOutcome { + const current = readJsBindingsConfig(workspaceDir); + if (!current.jsBindings) { + writeJsBindingsConfig(workspaceDir, defaultJsBindingsConfig()); + if (!opts.quiet) { + console.log( + 'ℹ️ Added "winapp.jsBindings" to package.json. ' + 'Edit it to customize package scope, extraTypes, etc.' + ); + } + return 'added'; } + if (opts.reset) { + writeJsBindingsConfig(workspaceDir, defaultJsBindingsConfig()); + if (!opts.quiet) { + console.log('ℹ️ Reset "winapp.jsBindings" in package.json to defaults.'); + } + return 'reset'; + } + return 'unchanged'; } /** @@ -104,9 +158,10 @@ export function hasJsBindings(workspaceDir: string): boolean { * package.json. * * Behaviour: - * * Preserves the existing 2-space indent + trailing newline. We do not - * pull in `prettier` for this single edit — JSON.stringify gives us a - * stable canonical layout and `package.json` is the only file we own. + * * Preserves the existing 2-space indent + trailing newline (via + * `mutatePackageJsonDoc`). We do not pull in `prettier` for this single + * edit — JSON.stringify gives us a stable canonical layout and + * `package.json` is the only file we own. * * Atomic: writes to a sibling temp file, fsyncs, then renames over the * real file so a half-written package.json is never visible. * * Inserts the `"winapp"` key at the end of the top-level object when it @@ -115,29 +170,23 @@ export function hasJsBindings(workspaceDir: string): boolean { * * Throws when package.json is missing or malformed; callers should * ensure the file exists (e.g. by suggesting `npm init -y`) before * writing. + * * Throws when the workspace path is UNC or has a reparse-point ancestor. */ export function writeJsBindingsConfig(workspaceDir: string, config: JsBindingsConfig): void { - const filePath = path.join(workspaceDir, PACKAGE_JSON); - if (!fs.existsSync(filePath)) { + if (!packageJsonExists(workspaceDir)) { throw new Error( `package.json not found in ${workspaceDir}. ` + 'Run `npm init -y` (or equivalent) before adding JS bindings configuration.' ); } - const raw = fs.readFileSync(filePath, 'utf8'); - const parsed = JSON.parse(raw); - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error(`Unexpected JSON shape in ${filePath}: top-level value must be an object.`); - } - - const existingNs = parsed.winapp && typeof parsed.winapp === 'object' ? parsed.winapp : {}; - parsed.winapp = { ...existingNs, jsBindings: serializeConfig(config) }; - - const eol = detectEol(raw); - const trailing = raw.endsWith('\n') ? eol : ''; - const serialized = JSON.stringify(parsed, null, 2).replace(/\n/g, eol) + trailing; - - atomicWriteFileSync(filePath, serialized); + mutatePackageJsonDoc(workspaceDir, (parsed) => { + const existingNs = parsed.winapp; + const ns = + existingNs && typeof existingNs === 'object' && !Array.isArray(existingNs) + ? (existingNs as Record) + : {}; + parsed.winapp = { ...ns, jsBindings: serializeConfig(config) }; + }); } /** @@ -232,44 +281,6 @@ function serializeConfig(config: JsBindingsConfig): Record { }; } -function detectEol(content: string): string { - // Match the file's predominant line ending so we don't accidentally - // rewrite CRLF → LF (or vice versa) on Windows checkouts. - return content.includes('\r\n') ? '\r\n' : '\n'; -} - -function atomicWriteFileSync(filePath: string, content: string): void { - const dir = path.dirname(filePath); - const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`); - let cleanup = true; - try { - const fd = fs.openSync(tmp, 'w'); - try { - fs.writeFileSync(fd, content); - try { - fs.fsyncSync(fd); - } catch { - // fsync isn't supported on every platform (e.g. some FUSE mounts on - // CI); the rename itself is enough for atomicity on POSIX and NTFS. - } - } finally { - fs.closeSync(fd); - } - fs.renameSync(tmp, filePath); - cleanup = false; - } finally { - if (cleanup) { - try { - fs.unlinkSync(tmp); - } catch { - // best-effort temp cleanup - } - } - } -} - // Re-exported so callers don't have to know whether the implementation lives // in this module or elsewhere. -export const PACKAGE_JSON_FILENAME = PACKAGE_JSON; -// Hint: os.EOL is intentionally unused — we prefer the file's existing EOL. -void os; +export { PACKAGE_JSON_FILENAME } from './package-json-doc'; diff --git a/src/winapp-npm/src/jsbindings/package-json-doc.ts b/src/winapp-npm/src/jsbindings/package-json-doc.ts new file mode 100644 index 00000000..25170a24 --- /dev/null +++ b/src/winapp-npm/src/jsbindings/package-json-doc.ts @@ -0,0 +1,200 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. +// +// Shared helper for every wrapper site that reads or writes the user's +// `package.json`. Centralises: +// * path-safety guard (workspace-rooted, UNC / reparse-point refusal); +// * JSON parse + structural validation (root must be an object); +// * EOL detection + trailing-newline preservation (matching the file's +// existing convention rather than forcing LF on a CRLF checkout); +// * atomic temp-file + rename, with copy+unlink fallback for cross-volume +// edge cases (AV interference, mapped network shares, etc). +// +// Consumers (package-json-config.ts, runtime-dep-injector.ts) should NEVER +// open-code `fs.readFileSync(packageJson)` / `JSON.parse` / `fs.renameSync` +// — go through `readPackageJsonDoc` / `mutatePackageJsonDoc` so changes to +// safety policy land in one place. + +import * as fs from 'fs'; +import * as path from 'path'; +import { assertSafeWorkspaceFile } from './path-safety'; + +export const PACKAGE_JSON_FILENAME = 'package.json'; + +export interface PackageJsonDoc { + /** Absolute path of the read file. */ + filePath: string; + /** Parsed JSON object (top-level must be an object — arrays / scalars throw). */ + parsed: Record; + /** Original raw text — useful for downstream diff / unchanged checks. */ + raw: string; + /** `'\r\n'` if the file uses CRLF anywhere, else `'\n'`. */ + eol: string; + /** True if the original file ended with a newline; we preserve this on write. */ + trailingNewline: boolean; +} + +/** + * Path-safety-guarded existence check. Returns false when: + * * package.json doesn't exist in the workspace, + * * OR the workspace itself / package.json path is UNC / reparse-backed + * (we treat unsafe paths as "not present" so callers fall through to + * their "no package.json" branch without leaking that we even probed). + * + * Use the boolean form when you just need to gate behaviour (`hasJsBindings`, + * "should I trigger codegen?"). Use `readPackageJsonDoc` when you need the + * parsed contents — that helper *throws* on safety violations. + */ +export function packageJsonExists(workspaceDir: string): boolean { + const filePath = path.join(workspaceDir, PACKAGE_JSON_FILENAME); + try { + assertSafeWorkspaceFile(workspaceDir, filePath, PACKAGE_JSON_FILENAME); + } catch { + return false; + } + return fs.existsSync(filePath); +} + +/** + * Read + parse package.json. Returns `null` when the file does not exist. + * + * Throws when: + * * the workspace / file path is UNC or has a reparse-point ancestor + * (defence-in-depth against hostile workspace layouts); + * * the file is not valid JSON; + * * the top-level JSON value is not an object. + * + * Callers that need a "missing-or-malformed → silent fall-through" semantic + * should call `packageJsonExists` first and then handle parse errors + * themselves; the default behaviour here is "fail loud" so a malformed + * package.json surfaces the real parse error instead of being silently + * swallowed. + */ +export function readPackageJsonDoc(workspaceDir: string): PackageJsonDoc | null { + const filePath = path.join(workspaceDir, PACKAGE_JSON_FILENAME); + assertSafeWorkspaceFile(workspaceDir, filePath, PACKAGE_JSON_FILENAME); + if (!fs.existsSync(filePath)) { + return null; + } + + let raw: string; + try { + raw = fs.readFileSync(filePath, 'utf8'); + } catch (err) { + throw new Error(`Failed to read ${filePath}: ${(err as Error).message}`, { cause: err }); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + throw new Error(`Failed to parse ${filePath}: ${(err as Error).message}`, { cause: err }); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`Unexpected JSON shape in ${filePath}: top-level value must be an object.`); + } + + return { + filePath, + parsed: parsed as Record, + raw, + eol: raw.includes('\r\n') ? '\r\n' : '\n', + trailingNewline: raw.endsWith('\n'), + }; +} + +/** + * Read package.json, apply `mutate` to the parsed object, then atomically + * write the result back. `mutate` may either: + * * mutate the object in place (returning `void`), or + * * return a new object (e.g. when reordering keys to preserve layout). + * + * Throws if package.json doesn't exist — callers that want a no-op when the + * file is missing should branch on `packageJsonExists` first. + */ +export function mutatePackageJsonDoc( + workspaceDir: string, + mutate: (parsed: Record) => void | Record +): void { + const doc = readPackageJsonDoc(workspaceDir); + if (!doc) { + throw new Error( + `package.json not found in ${workspaceDir}. ` + 'Run `npm init -y` (or equivalent) before mutating package.json.' + ); + } + + const result = mutate(doc.parsed); + const next = result ?? doc.parsed; + + const serialized = JSON.stringify(next, null, 2).replace(/\n/g, doc.eol); + const final = doc.trailingNewline && !serialized.endsWith(doc.eol) ? serialized + doc.eol : serialized; + + atomicWriteFile(doc.filePath, final); +} + +/** + * Atomically write `content` to `filePath`. Strategy: + * 1. Write to a sibling temp file in the same directory (same-volume). + * 2. `fsync` to flush kernel buffers to disk (best-effort — some FUSE / + * network filesystems don't implement it; ignore the error). + * 3. `renameSync` over the destination. On NTFS / ext4 this is atomic at + * the directory-entry level: readers see either the old file or the new + * file, never a half-written one. + * 4. On rename failure (cross-volume, AV interference, sharing violation), + * fall back to `copyFileSync` + `unlinkSync` — non-atomic, but at least + * we don't leave the destination empty. + * + * Exported so future workspace writers (other config files, lockfile-writer + * mirrors, etc.) can use the same primitive instead of re-implementing. + */ +export function atomicWriteFile(filePath: string, content: string): void { + const dir = path.dirname(filePath); + const tmpName = `.${path.basename(filePath)}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`; + const tmpPath = path.join(dir, tmpName); + + let staged = false; + try { + const fd = fs.openSync(tmpPath, 'w'); + try { + fs.writeFileSync(fd, content); + try { + fs.fsyncSync(fd); + } catch { + // fsync unsupported on some platforms (e.g., certain FUSE mounts on CI). + } + } finally { + fs.closeSync(fd); + } + staged = true; + + fs.renameSync(tmpPath, filePath); + staged = false; + return; + } catch (renameErr) { + if (staged) { + // Fallback: copy then unlink. Not atomic, but better than leaving the + // destination empty or the user's package.json half-written. + try { + fs.copyFileSync(tmpPath, filePath); + try { + fs.unlinkSync(tmpPath); + } catch { + /* leaked tmp is harmless */ + } + return; + } catch (fallbackErr) { + try { + fs.unlinkSync(tmpPath); + } catch { + /* ignore */ + } + throw new Error( + `Failed to write ${filePath}: ${(fallbackErr as Error).message} ` + + `(after rename error: ${(renameErr as Error).message})`, + { cause: fallbackErr } + ); + } + } + throw new Error(`Failed to write ${filePath}: ${(renameErr as Error).message}`, { cause: renameErr }); + } +} diff --git a/src/winapp-npm/src/jsbindings/path-safety.ts b/src/winapp-npm/src/jsbindings/path-safety.ts index 8a082647..d6cc2e97 100644 --- a/src/winapp-npm/src/jsbindings/path-safety.ts +++ b/src/winapp-npm/src/jsbindings/path-safety.ts @@ -107,3 +107,77 @@ function isReparseSegment(p: string): boolean { } return stat.isSymbolicLink(); } + +/** + * Throw if `filePath` (or any segment from `workspaceDir` down to it) is a + * reparse point or UNC path. Single chokepoint for every file we read or + * write inside the user's workspace (`package.json`, `winapp.yaml`, + * `.winapp/winmds.lock.json`, codegen output). Mirrors the native side's + * `IsLockfilePathUnsafe()` / `PathSafety.AssertSafeWrite`. + * + * `label` is woven into the error message so the user can tell which file + * tripped the guard (`'package.json'`, `'winmds.lock.json'`, …). + */ +export function assertSafeWorkspaceFile(workspaceDir: string, filePath: string, label: string): void { + if (isNetworkPath(workspaceDir) || isNetworkPath(filePath)) { + throw new Error( + `Refusing to access ${label} at '${filePath}': workspace or target path is a UNC / network path. ` + + 'Use a local drive-letter path.' + ); + } + if (hasReparsePointOnPath(filePath, workspaceDir)) { + throw new Error( + `Refusing to access ${label} at '${filePath}': the file or one of its ` + + `ancestors below '${workspaceDir}' is a reparse point (symlink / junction) ` + + 'or the file is outside the workspace. Resolve the link and re-run.' + ); + } +} + +/** + * Stricter variant for directories that the wrapper will RECURSIVELY DELETE + * before each run (e.g. dynwinrt-codegen output). Requires: + * * `outputDir` is strictly *inside* the workspace (not equal to it); + * * neither end of the path is UNC / network; + * * no segment from workspace down to outputDir is a reparse point; + * * if `outputDir` already exists, it is itself not a reparse point. + * + * Throws a labelled error on any violation. Returns the resolved absolute + * path on success. + */ +export function assertSafeWorkspaceOutputDir(workspaceDir: string, outputDir: string, label: string): string { + if (!outputDir || !outputDir.trim()) { + throw new Error(`${label} must not be empty.`); + } + if (isNetworkPath(workspaceDir) || isNetworkPath(outputDir)) { + throw new Error( + `Refusing to use ${label} at '${outputDir}': workspace or output path is a UNC / network path. ` + + 'Use a local drive-letter path.' + ); + } + + const resolvedOutput = path.isAbsolute(outputDir) ? path.resolve(outputDir) : path.resolve(workspaceDir, outputDir); + const resolvedWorkspace = path.resolve(workspaceDir).replace(/[\\/]+$/, ''); + const prefix = resolvedWorkspace + path.sep; + const insideWorkspace = + resolvedOutput.length > prefix.length && resolvedOutput.toLowerCase().startsWith(prefix.toLowerCase()); + + if (!insideWorkspace) { + throw new Error( + `${label} ('${outputDir}') resolves to '${resolvedOutput}' which is outside the workspace ` + + `('${resolvedWorkspace}'). The directory is wiped before each run, so it must be a ` + + "path strictly inside the workspace. Use a relative path like 'bindings' or an absolute path " + + 'that descends from the workspace root.' + ); + } + + if (hasReparsePointOnPath(resolvedOutput, resolvedWorkspace)) { + throw new Error( + `${label} ('${outputDir}') resolves through a reparse point under '${resolvedWorkspace}'. ` + + 'Reparse points (symlinks / junctions) are rejected because the wipe could follow them ' + + 'outside the workspace. Move the output to a regular subdirectory.' + ); + } + + return resolvedOutput; +} diff --git a/src/winapp-npm/src/jsbindings/runtime-dep-injector.ts b/src/winapp-npm/src/jsbindings/runtime-dep-injector.ts index 9db0ff26..4865b146 100644 --- a/src/winapp-npm/src/jsbindings/runtime-dep-injector.ts +++ b/src/winapp-npm/src/jsbindings/runtime-dep-injector.ts @@ -7,18 +7,20 @@ // // Ported from C# `UserPackageJsonService.cs`. Key invariants: // * Refuse to write through reparse-point ancestors (symlinks / junctions) -// — same protection the native side enforced via PathSafety. +// — same protection the native side enforced via PathSafety. Provided +// transitively by `package-json-doc.ts`. // * Preserve unrelated keys exactly. Insert "dependencies" right after // "version" when creating the block from scratch. // * Atomic write via sibling tmp + fs.renameSync (Windows: same-volume rename -// is atomic; fall back to copy+unlink if the rename fails). +// is atomic; fall back to copy+unlink if the rename fails). Provided by +// `package-json-doc.atomicWriteFile`. // * Don't auto-promote a dev→prod dep — the user pinned it under dev for a // reason; report `PresentInDevDependencies` instead. import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { hasReparsePointOnPath } from './path-safety'; +import { readPackageJsonDoc, mutatePackageJsonDoc } from './package-json-doc'; export type RuntimeDependencyOutcome = 'added' | 'alreadyPresent' | 'presentInDevDependencies' | 'noPackageJson'; @@ -40,40 +42,14 @@ export function ensureRuntimeDependency( throw new Error('version must not be empty'); } - const packageJsonPath = path.join(workspaceDir, 'package.json'); - - // Refuse to follow reparse points / UNC ancestors BEFORE probing existence. - if (hasReparsePointOnPath(packageJsonPath, workspaceDir)) { - throw new Error( - `Refusing to rewrite '${packageJsonPath}': the file or one of its ` + - 'ancestors is a symbolic link / reparse point. Resolve the link ' + - 'and re-run, or add the runtime dependency manually.' - ); - } - - if (!fs.existsSync(packageJsonPath)) { + // Single round-trip: readPackageJsonDoc enforces the path-safety guard and + // returns null when package.json is missing. + const doc = readPackageJsonDoc(workspaceDir); + if (!doc) { return { outcome: 'noPackageJson' }; } - let original: string; - try { - original = fs.readFileSync(packageJsonPath, 'utf8'); - } catch (err) { - throw new Error(`Failed to read ${packageJsonPath}: ${(err as Error).message}`, { cause: err }); - } - - let root: unknown; - try { - root = JSON.parse(original); - } catch (err) { - throw new Error(`Failed to parse ${packageJsonPath}: ${(err as Error).message}`, { cause: err }); - } - - if (!root || typeof root !== 'object' || Array.isArray(root)) { - throw new Error(`${packageJsonPath} root is not a JSON object.`); - } - - const obj = root as Record; + const obj = doc.parsed; const deps = obj.dependencies; if (deps && typeof deps === 'object' && !Array.isArray(deps)) { if (packageName in (deps as Record)) { @@ -88,14 +64,12 @@ export function ensureRuntimeDependency( } } - // Add to dependencies; insert the block right after "version" when creating it. - const rebuilt = insertOrUpdateDependency(obj, packageName, version); + // Mutate + atomic write through the shared helper. mutatePackageJsonDoc + // re-reads the file to avoid TOCTOU windows; it also re-runs the safety + // guard so a race that swaps in a reparse point between read and write is + // still refused. + mutatePackageJsonDoc(workspaceDir, (parsed) => insertOrUpdateDependency(parsed, packageName, version)); - // npm/yarn/pnpm conventionally use 2-space indent + trailing newline. - const serialized = JSON.stringify(rebuilt, null, 2); - const final = original.endsWith('\n') && !serialized.endsWith('\n') ? serialized + '\n' : serialized; - - atomicWriteFile(packageJsonPath, final); return { outcome: 'added', pinnedVersion: version }; } @@ -129,46 +103,6 @@ function insertOrUpdateDependency( return rebuilt; } -function atomicWriteFile(filePath: string, content: string): void { - const dir = path.dirname(filePath); - const tmpName = `${path.basename(filePath)}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}`; - const tmpPath = path.join(dir, tmpName); - let staged = false; - try { - fs.writeFileSync(tmpPath, content, { encoding: 'utf8' }); - staged = true; - // Windows: same-volume rename is atomic. fs.renameSync overwrites the target. - fs.renameSync(tmpPath, filePath); - staged = false; - } catch (err) { - // Fallback for the rare cross-volume case (or AV / sharing-violation - // races): copy+unlink. Not atomic, but better than leaving the file - // mid-write. - if (staged) { - try { - fs.copyFileSync(tmpPath, filePath); - try { - fs.unlinkSync(tmpPath); - } catch { - /* leaked tmp is harmless */ - } - return; - } catch (fallbackErr) { - try { - fs.unlinkSync(tmpPath); - } catch { - /* ignore */ - } - throw new Error( - `Failed to write ${filePath}: ${(fallbackErr as Error).message} (after rename error: ${(err as Error).message})`, - { cause: fallbackErr } - ); - } - } - throw new Error(`Failed to write ${filePath}: ${(err as Error).message}`, { cause: err }); - } -} - // Resolves the pinned `@microsoft/dynwinrt` version by reading the winapp-npm // package's own dependencies (the wrapper bundles dynwinrt-codegen, and // dynwinrt shares the same pin). Mirrors the C# `NpmWrapperVersionProvider`. diff --git a/src/winapp-npm/src/jsbindings/winmd-policy.ts b/src/winapp-npm/src/jsbindings/winmd-policy.ts index 7d37d3dd..61141741 100644 --- a/src/winapp-npm/src/jsbindings/winmd-policy.ts +++ b/src/winapp-npm/src/jsbindings/winmd-policy.ts @@ -98,10 +98,63 @@ export interface WinmdPartition { skipped: string[]; } -// Partition a list of winmd paths by category. `emitScope` (when provided) -// demotes out-of-scope emit packages to refOnly so codegen still sees their -// metadata for cross-package type resolution. Skip/refOnly classifications -// take precedence over scope. +/** Tuple shape: one entry per NuGet package, with its name and the winmds inside it. */ +export interface PackageWinmds { + name: string; + winmds: readonly string[]; +} + +/** + * Partition a list of `{name, winmds[]}` tuples by category, using the + * package name directly (no path extraction needed — the lockfile already + * groups winmds by package on the writer side). + * + * `emitScope` (when provided) demotes out-of-scope emit packages to refOnly + * so codegen still sees their metadata for cross-package type resolution. + * Skip/refOnly classifications take precedence over scope. + * + * Prefer this overload over `partitionByPackageCategory(string[], …)` when + * the source data is the lockfile — see orchestrator.ts. + */ +export function partitionPackageWinmds( + packages: readonly PackageWinmds[], + options?: { + overrides?: PackageCategoryOverrides; + emitScope?: readonly string[]; + } +): WinmdPartition { + const overrides = options?.overrides; + const scope = lowercaseSet(options?.emitScope); + + const emit: string[] = []; + const refOnly: string[] = []; + const skipped: string[] = []; + + for (const pkg of packages) { + if (!pkg || !pkg.name || !pkg.winmds || pkg.winmds.length === 0) { + continue; + } + let cat: WinmdPackageCategory = classifyPackage(pkg.name, overrides); + if (scope && cat === 'emit' && !scope.has(pkg.name.toLowerCase())) { + cat = 'refOnly'; + } + const bucket = cat === 'skip' ? skipped : cat === 'refOnly' ? refOnly : emit; + for (const w of pkg.winmds) { + bucket.push(w); + } + } + + return { emit, refOnly, skipped }; +} + +// Partition a flat list of winmd paths by category. Falls back to +// `extractPackageIdFromPath` for each entry — needed for loose user-supplied +// `additionalWinmds` / `additionalRefs` that don't carry their package +// identity. For lockfile-sourced winmds, use `partitionPackageWinmds` instead. +// +// `emitScope` (when provided) demotes out-of-scope emit packages to refOnly +// so codegen still sees their metadata for cross-package type resolution. +// Skip/refOnly classifications take precedence over scope. export function partitionByPackageCategory( winmds: readonly string[], options?: { diff --git a/src/winapp-npm/src/jsbindings/yaml-packages-hash.ts b/src/winapp-npm/src/jsbindings/yaml-packages-hash.ts new file mode 100644 index 00000000..266e8318 --- /dev/null +++ b/src/winapp-npm/src/jsbindings/yaml-packages-hash.ts @@ -0,0 +1,231 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. +// +// SHA-256 hex over canonicalized `lower(name)|version` lines from the +// workspace's `winapp.yaml` `packages:` block. +// +// Port of native `WinApp.Cli.Services.YamlPackagesHasher` — used as a +// staleness signal against the lockfile's `yaml_packages_hash`. If the user +// edits `winapp.yaml` (adds / removes / re-pins an SDK package) without +// re-running `winapp restore`, the orchestrator detects the drift and asks +// them to restore before generating bindings — otherwise we'd emit JS +// bindings for the wrong winmd set. +// +// The C# side computes the hash with `SHA256.HashData(UTF8(joined))`; the +// canonical form is the *exact* string we must reproduce here byte-for-byte: +// * each `name` lowercased via OrdinalIgnoreCase semantics +// (we use `.toLowerCase()` — both implementations match for ASCII, and +// every NuGet package id we accept is ASCII per NuGet's grammar) +// * pairs deduped (Ordinal compare) +// * sorted Ordinal-ascending +// * lines joined with `\n` (no trailing newline) +// * UTF-8 encoded, SHA-256, hex (lower-case) + +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; +import { assertSafeWorkspaceFile } from './path-safety'; + +export interface PackagePin { + name: string; + version: string; +} + +/** + * Compute the canonical hash for a sequence of name/version pairs. + * Whitespace-only names are skipped (matching C#'s `IsNullOrWhiteSpace`). + * `null`/`undefined` versions canonicalize to the empty string. + */ +export function computeYamlPackagesHash(packages: Iterable): string { + const lines = new Set(); + for (const p of packages) { + if (!p || typeof p.name !== 'string' || p.name.trim().length === 0) { + continue; + } + const version = typeof p.version === 'string' ? p.version : ''; + lines.add(`${p.name.toLowerCase()}|${version}`); + } + const sorted = [...lines].sort(); + const joined = sorted.join('\n'); + return crypto.createHash('sha256').update(joined, 'utf8').digest('hex'); +} + +/** + * Read `winapp.yaml` and extract its `packages:` pins using a tiny line-based + * scanner. Mirrors the native CLI's hand-rolled grammar in + * {@link ../../winapp-CLI/WinApp.Cli/Services/WinappConfigDocument.cs}: + * + * * top-level key with optional inline `# comment` + * * list entries via `- name: ` then `version: ` + * * unknown top-level keys reset the parser (children are ignored) + * + * We deliberately do NOT pull in a full YAML parser — this routine runs on + * every restore / generate-bindings and the grammar is intentionally tiny. + * Falls back to `null` (rather than throwing) when the file is missing or + * malformed; the caller treats that as "can't detect drift" and proceeds. + * + * Path safety: + * * Always reparse-point-guarded. Default location (`/winapp.yaml`) + * uses the workspace itself as the trust boundary. Explicit `yamlPath` + * (e.g. user passed `--config-dir ../other`) uses the file's containing + * directory as the boundary — this mirrors the native CLI's + * `ConfigService.GuardConfigPath` (boundary = `ConfigPath.DirectoryName`), + * so a workspace-internal junction/symlink redirecting to an attacker + * path is refused regardless of whether the caller pointed at the + * default location or an explicit `--config-dir`. + */ +export function readWinappYamlPackages(workspaceDir: string, yamlPath?: string): PackagePin[] | null { + const defaultPath = path.join(workspaceDir, 'winapp.yaml'); + const resolved = yamlPath ? path.resolve(yamlPath) : defaultPath; + + // Trust boundary: workspace for the default path, file's containing + // directory for an explicit path. The latter matches the native + // ConfigService's `ConfigPath.DirectoryName` boundary, which is also + // the boundary `--config-dir` legitimately escapes out of. + const safetyBoundary = resolved === defaultPath ? workspaceDir : path.dirname(resolved); + assertSafeWorkspaceFile(safetyBoundary, resolved, 'winapp.yaml'); + + if (!fs.existsSync(resolved)) { + return null; + } + let raw: string; + try { + raw = fs.readFileSync(resolved, 'utf8'); + } catch { + return null; + } + return parsePackagesFromYaml(raw); +} + +/** Visible for unit tests. */ +export function parsePackagesFromYaml(yaml: string): PackagePin[] { + const lines = yaml.split(/\r?\n/); + const packages: PackagePin[] = []; + let inPackages = false; + let currentName: string | null = null; + for (const rawLine of lines) { + const indent = leadingSpaces(rawLine); + const t = rawLine.trim(); + if (t.length === 0 || t.startsWith('#')) { + continue; + } + if (indent === 0) { + // Top-level key boundary. + inPackages = isTopLevelKey(t, 'packages:'); + currentName = null; + continue; + } + if (!inPackages) { + continue; + } + // List item or bare `name:` / `version:` row. + const dashName = matchPrefixCaseInsensitive(t, '- name:'); + if (dashName !== null) { + currentName = sanitizeScalar(dashName); + continue; + } + const bareName = matchPrefixCaseInsensitive(t, 'name:'); + if (bareName !== null) { + currentName = sanitizeScalar(bareName); + continue; + } + const version = matchPrefixCaseInsensitive(t, 'version:'); + if (version !== null && currentName !== null) { + packages.push({ name: currentName, version: sanitizeScalar(version) }); + currentName = null; + } + } + return packages; +} + +function leadingSpaces(line: string): number { + let i = 0; + while (i < line.length && line[i] === ' ') { + i++; + } + return i; +} + +function isTopLevelKey(trimmed: string, key: string): boolean { + if (!trimmed.toLowerCase().startsWith(key.toLowerCase())) { + return false; + } + if (trimmed.length === key.length) { + return true; + } + const rest = trimmed.slice(key.length).trimStart(); + return rest.length === 0 || rest.startsWith('#'); +} + +function matchPrefixCaseInsensitive(trimmed: string, prefix: string): string | null { + if (trimmed.toLowerCase().startsWith(prefix.toLowerCase())) { + return trimmed.slice(prefix.length); + } + return null; +} + +/** + * Strip inline `# comment` from an unquoted scalar; peel a single pair of + * matching surrounding quotes; un-double `''` inside single-quoted scalars. + * Port of `WinappConfigDocument.SanitizeScalar` — we only handle what the + * native renderer actually emits, not the full YAML 1.2 scalar grammar. + */ +function sanitizeScalar(raw: string): string { + if (!raw) { + return ''; + } + const trimmed = raw.replace(/^\s+/, ''); + if (trimmed.length === 0) { + return ''; + } + const opener = trimmed[0] === '"' || trimmed[0] === "'" ? trimmed[0] : null; + let cutoff = trimmed.length; + let inSingle = false; + let inDouble = false; + const trackQuoteState = opener !== null; + for (let i = 0; i < trimmed.length; i++) { + const c = trimmed[i]; + if (trackQuoteState) { + if (inDouble) { + if (c === '\\' && i + 1 < trimmed.length) { + i++; + continue; + } + if (c === '"') { + inDouble = false; + } + continue; + } + if (inSingle) { + if (c === "'") { + inSingle = false; + } + continue; + } + if (c === '"') { + inDouble = true; + continue; + } + if (c === "'") { + inSingle = true; + continue; + } + } + if (c === '#') { + // YAML requires whitespace before an unquoted inline comment. + if (i === 0 || /\s/.test(trimmed[i - 1])) { + cutoff = i; + break; + } + } + } + const value = trimmed.slice(0, cutoff).replace(/\s+$/, ''); + if (value.length >= 2 && opener && value[0] === opener && value[value.length - 1] === opener) { + const inner = value.slice(1, -1); + if (opener === "'") { + return inner.replace(/''/g, "'"); + } + return inner; + } + return value; +} diff --git a/src/winapp-npm/src/winapp-cli-utils.ts b/src/winapp-npm/src/winapp-cli-utils.ts index d7607a33..f4eef7e8 100644 --- a/src/winapp-npm/src/winapp-cli-utils.ts +++ b/src/winapp-npm/src/winapp-cli-utils.ts @@ -7,6 +7,8 @@ export const WINAPP_CLI_CALLER_VALUE = 'nodejs-package'; export interface CallWinappCliOptions { exitOnError?: boolean; + /** Working directory for the spawned process (defaults to process.cwd()). */ + cwd?: string; } export interface CallWinappCliResult { @@ -49,13 +51,13 @@ export function getWinappCliPath(): string { * Always captures output and returns it along with the exit code */ export async function callWinappCli(args: string[], options: CallWinappCliOptions = {}): Promise { - const { exitOnError = false } = options; + const { exitOnError = false, cwd = process.cwd() } = options; const winappCliPath = getWinappCliPath(); return new Promise((resolve, reject) => { const child = spawn(winappCliPath, args, { stdio: 'inherit', - cwd: process.cwd(), + cwd, shell: false, env: { ...process.env, From 7bd4e18e0d66a097661269822334570b3f8780f5 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Fri, 22 May 2026 10:50:24 +0800 Subject: [PATCH 14/27] fix bug and clean the code change --- .github/plugin/agents/winapp.agent.md | 20 +- .../skills/winapp-cli/frameworks/SKILL.md | 28 +- .../plugin/skills/winapp-cli/setup/SKILL.md | 43 +- .../skills/winapp-cli/ui-automation/SKILL.md | 2 +- .../fragments/skills/winapp-cli/frameworks.md | 28 +- docs/fragments/skills/winapp-cli/setup.md | 31 +- .../skills/winapp-cli/ui-automation.md | 2 +- docs/guides/electron/index.md | 10 +- docs/guides/electron/js-bindings.md | 366 +++++++++++++++++ docs/guides/electron/jsbindings.md | 184 --------- docs/js-bindings.md | 388 ------------------ docs/npm-usage.md | 1 + docs/usage.md | 4 +- scripts/generate-llm-docs.ps1 | 2 +- .../WinApp.Cli.Tests/BaseCommandTests.cs | 24 +- .../WinApp.Cli.Tests/InitCommandTests.cs | 4 +- .../WorkspaceSetupServiceTests.cs | 23 +- .../WinApp.Cli/Commands/InitCommand.cs | 2 +- .../WinApp.Cli/Services/IConfigService.cs | 2 - .../Services/IWorkspaceSetupService.cs | 6 +- .../Services/WorkspaceSetupService.Init.cs | 17 +- .../Services/WorkspaceSetupService.cs | 11 + .../src/jsbindings/additional-winmds.ts | 91 ++-- .../src/jsbindings/codegen-runner.ts | 30 +- src/winapp-npm/src/jsbindings/orchestrator.ts | 61 +-- .../src/jsbindings/package-json-config.ts | 68 ++- src/winapp-npm/src/jsbindings/winmd-policy.ts | 79 +--- 27 files changed, 609 insertions(+), 918 deletions(-) create mode 100644 docs/guides/electron/js-bindings.md delete mode 100644 docs/guides/electron/jsbindings.md delete mode 100644 docs/js-bindings.md diff --git a/.github/plugin/agents/winapp.agent.md b/.github/plugin/agents/winapp.agent.md index 80b82299..0246d088 100644 --- a/.github/plugin/agents/winapp.agent.md +++ b/.github/plugin/agents/winapp.agent.md @@ -28,8 +28,6 @@ Does the project already have an appxmanifest.xml? └─ Yes ├─ Has winapp.yaml, cloned/pulled but .winapp/ folder is missing? │ └─ winapp restore - ├─ Want to add typed JS/TypeScript WinRT bindings to an existing workspace? - │ └─ npx winapp node generate-bindings (adds default `"winapp": { "jsBindings": {} }` to package.json on first use, then generates. Requires a prior `winapp restore` so the winmd lockfile exists.) ├─ Want to check for newer SDK versions? │ └─ winapp update ├─ Only need an appxmanifest.xml (no SDKs, no cert, no config)? @@ -92,8 +90,8 @@ Want to inspect or interact with a running app's UI? **Creates:** `winapp.yaml`, `appxmanifest.xml`, `Assets/` folder, `.winapp/` (if SDKs installed) ### `winapp restore [base-directory]` -**Purpose:** Reinstall SDK packages from existing config without changing versions. Also re-runs JS/TS binding codegen when `package.json` declares a `"winapp.jsBindings"` namespace. -**When to use:** After cloning a repo that has `winapp.yaml`, when the `.winapp/` folder is missing/corrupted, or after editing the `winapp.jsBindings` namespace in `package.json` by hand. +**Purpose:** Reinstall SDK packages from existing config without changing versions. +**When to use:** After cloning a repo that has `winapp.yaml`, or when the `.winapp/` folder is missing/corrupted. **Requires:** `winapp.yaml` ### `winapp update` @@ -102,15 +100,6 @@ Want to inspect or interact with a running app's UI? **Key options:** `--setup-sdks stable|preview|experimental|none` **Requires:** `winapp.yaml` -### JS/TS bindings (npm-only, via `init` / `restore` / `node generate-bindings`) -**Purpose:** Generate typed JS/TS WinRT bindings (via `@microsoft/dynwinrt-codegen`) so Node / Electron apps can call WinRT APIs directly without a native build step. -**When to use:** Inside a Node / Electron project after `npx winapp init`. -**How to enable:** -- **Fresh init via npm shim** (`npx winapp init`) shows an interactive yes/no prompt — `Add JS/TypeScript bindings to this project? [Y/n]:`. Press Enter (or pass `--use-defaults`) to opt in; the wrapper writes a default `"winapp": { "jsBindings": {} }` namespace (covering the full Windows App SDK) into `package.json`. C++ projections are always generated regardless of the answer. -- **Existing workspace:** run `npx winapp node generate-bindings`. It adds a default `"winapp": { "jsBindings": {} }` namespace to `package.json` (covering the full Windows App SDK) on first use, then immediately generates against the cached winmd lockfile. Requires a prior `winapp restore` so the lockfile exists; if not, the command tells you to run `winapp restore` first. -- **Re-run codegen** after editing `winapp.jsBindings.packages` / `extraTypes` / `additionalWinmds` by hand: `npx winapp node generate-bindings` is the fast path — it reuses the cached `.winapp/winmds.lock.json` and skips the NuGet download / cppwinrt header regen that `winapp restore` does. Use `npx winapp restore` instead when you changed `winapp.yaml` (packages, sdkVersion, etc.) so the lockfile is refreshed first. -**Notes:** npm-only — the interactive prompt only fires when invoked through `npx winapp …`. Standalone winget / installer builds do not generate JS bindings. Codegen always auto-injects `@microsoft/dynwinrt` as a production dep into `package.json`. See [JS bindings docs](https://github.com/microsoft/winappcli/blob/main/docs/js-bindings.md) for the full `winapp.jsBindings` schema. - ### `winapp package ` (alias: `winapp pack`) **Purpose:** Create an MSIX installer from a built app. **When to use:** After building your app, when you want to create a distributable MSIX package. @@ -229,7 +218,10 @@ Want to inspect or interact with a running app's UI? ### Electron - **Setup:** `winapp init --use-defaults` → choose your Windows API access path: - - **JS bindings (easiest, npm-only):** at the `npx winapp init` prompt answer **Y** (the default — or pass `--use-defaults`). On an existing workspace, run `npx winapp node generate-bindings` (adds the default `winapp.jsBindings` block on first use, then generates immediately from the cached winmd lockfile). Generates typed `bindings/*.{js,d.ts}` for the full Windows App SDK surface, callable directly from your main/renderer process via dynwinrt. No native build step. + - **JS bindings** — typed `bindings/*.{js,d.ts}` covering the Windows App SDK, called via `@microsoft/dynwinrt` (no native build step). + - **Add:** `npx winapp init` (interactive prompt) or `npx winapp node generate-bindings` on an existing project. Both write a default `winapp.jsBindings` namespace into `package.json` if missing, then generate. + - **Re-run:** `npx winapp node generate-bindings` after editing `winapp.jsBindings.{packages,extraTypes,additionalWinmds}`. Use `npx winapp restore` instead when you changed `winapp.yaml`. + - Codegen injects `@microsoft/dynwinrt` as a production dep — run `npm install` afterwards to materialize it. - **Native addons:** `winapp node create-addon --template cs` (or `--template cpp`) for C#/C++ addons when you need full WinRT access or stateful native services. - Then: `winapp node add-electron-debug-identity` to enable identity-required APIs. - **Package:** Build with your packager (e.g., Electron Forge), then `winapp package --cert .\devcert.pfx` diff --git a/.github/plugin/skills/winapp-cli/frameworks/SKILL.md b/.github/plugin/skills/winapp-cli/frameworks/SKILL.md index 122d2b06..dd952dd8 100644 --- a/.github/plugin/skills/winapp-cli/frameworks/SKILL.md +++ b/.github/plugin/skills/winapp-cli/frameworks/SKILL.md @@ -30,37 +30,35 @@ Use the **npm package** (`@Microsoft/WinAppCli`), **not** the standalone CLI. Th - The native winapp CLI binary bundled inside `node_modules` - A Node.js SDK with helpers for creating native C#/C++ addons - Electron-specific commands under `npx winapp node` -- An interactive yes/no bindings prompt during `npx winapp init` — `Add JS/TypeScript bindings to this project? [Y/n]:` — that opts your project into typed JS/TypeScript WinRT wrappers via dynwinrt (no native build required) +- Typed JS/TypeScript WinRT bindings via dynwinrt (no native build required), opt-in during `npx winapp init` or via `npx winapp node generate-bindings` Quick start: ```powershell npm install --save-dev @microsoft/winappcli -npx winapp init --use-defaults # init + generate full Windows App SDK JS bindings AND C++ projections (default: Both) -# (interactive: omit --use-defaults to pick C++ / JS / Both at the prompt) +npx winapp init --use-defaults # fresh init: scaffolds winapp.yaml + JS bindings + C++ projections +npx winapp node generate-bindings # existing project: add (or re-run) JS bindings only npx winapp node create-addon --template cs # create a C# native addon (for what dynwinrt can't drive — see below) npx winapp node add-electron-debug-identity # register identity for debugging ``` -JS/TS bindings (the `"winapp": { "jsBindings": {...} }` namespace in `package.json`) are **npm-only** — they require invocation via the `@microsoft/winappcli` npm package because they pull in `@microsoft/dynwinrt-codegen` as a transitive dep. The bindings prompt does not appear when running the standalone winget CLI. - #### Choosing between jsBindings and a native addon -The decision is almost entirely about the **shape of the API**, not preference. +The decision is about the **shape of the API**, not preference. -**Default: if the API is WinRT (ships in a `.winmd`), pick JS bindings at the `npx winapp init` prompt.** That covers nearly everything an Electron app actually calls on Windows — `Microsoft.Windows.*` (Notifications, FilePickers, Sensors, Storage, AI inference like `TextRecognizer` / `LanguageModel`), most of `Windows.*`, and all of `Microsoft.WindowsAppSDK.AI`. dynwinrt's [own scope statement](https://github.com/microsoft/dynwinrt#scope) sums it up as "non-UI WinRT APIs ... headless services from the Windows SDK and WinAppSDK". +**Default — WinRT API (ships in a `.winmd`) → JS bindings.** Covers most of `Microsoft.Windows.*` (Notifications, FilePickers, Sensors, AI like `TextRecognizer` / `LanguageModel`), `Windows.*`, and `Microsoft.WindowsAppSDK.AI`. See [dynwinrt scope](https://github.com/microsoft/dynwinrt#scope). -**Fall back to `node create-addon` when one of these is true:** +**Fall back to `node create-addon` when there's no `.winmd`:** -| Scenario | Template | Why dynwinrt can't help | -|---|---|---| -| The API is **Win32 / pure COM with no WinRT projection** (P/Invoke-style APIs, raw `IFileDialog`, registry, custom COM servers). | `--template cpp` | No `.winmd` exists, so there's nothing for the codegen to project. | -| You're integrating a **C++ library that ships only headers + a static/shared lib** (no `.winmd`). | `--template cpp` | Same — dynwinrt requires WinRT metadata. | -| You're integrating a **vendor SDK that only ships a managed .NET assembly** (no `.winmd`). | `--template cs` (uses [node-api-dotnet](https://github.com/microsoft/node-api-dotnet) under the hood). | Same — no WinRT projection to consume. | +| Scenario | Template | +|---|---| +| Win32 / pure COM (P/Invoke, raw `IFileDialog`, registry, custom COM servers) | `--template cpp` | +| C++ library (headers + static/shared lib only) | `--template cpp` | +| Managed .NET assembly only (vendor SDK) | `--template cs` ([node-api-dotnet](https://github.com/microsoft/node-api-dotnet)) | -It's normal to mix both in one app: jsBindings for the non-UI WinRT surface, a small C# or C++ addon for the one or two Win32 / non-WinRT calls that don't fit. +Mixing both in one app is normal. Additional Electron guides: -- [JS bindings reference](https://github.com/microsoft/WinAppCli/blob/main/docs/js-bindings.md) — full `winapp.jsBindings` JSON schema, per-package classification, lockfile +- [JS bindings guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/js-bindings.md) — full `winapp.jsBindings` JSON schema, per-package classification, lockfile - [Packaging guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/packaging.md) - [C++ notification addon guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/cpp-notification-addon.md) - [WinML addon guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/winml-addon.md) diff --git a/.github/plugin/skills/winapp-cli/setup/SKILL.md b/.github/plugin/skills/winapp-cli/setup/SKILL.md index 976e5050..99c54444 100644 --- a/.github/plugin/skills/winapp-cli/setup/SKILL.md +++ b/.github/plugin/skills/winapp-cli/setup/SKILL.md @@ -52,41 +52,14 @@ winapp init --use-defaults --setup-sdks none winapp init --use-defaults --setup-sdks preview ``` -### Add JS/TS bindings for Node / Electron apps (npm only) - -When invoked via the `@microsoft/winappcli` npm package (i.e. `npx winapp init` -inside a Node / Electron project), `init` adds an interactive yes/no **bindings -prompt** — `Add JS/TypeScript bindings to this project? [Y/n]:`. Answering Yes -(the default) wires a default `"winapp": { "jsBindings": {} }` namespace into -`package.json` (covering the full Windows App SDK) and runs codegen as part of -init. C++ projections are always generated regardless of the answer. - -```powershell -# Interactive — prompted with the yes/no question. -npx winapp init - -# Non-interactive — auto-answers Yes (opts in to JS bindings). -npx winapp init --use-defaults - -# After editing winapp.jsBindings in package.json by hand (or pulling a -# teammate's package.json), regenerate bindings without re-prompting: -# Fast path — reuses the cached lockfile, no NuGet / cppwinrt re-run. -npx winapp node generate-bindings - -# Use the full restore instead if you also changed winapp.yaml (packages, -# sdkVersion, ...) — it refreshes the lockfile before re-running codegen. -npx winapp restore -``` - -Generated files land under `bindings/` and `@microsoft/dynwinrt` is -added to your `package.json` dependencies so production installs include it. - After `init`, your project will contain: - `Package.appxmanifest` — package identity and capabilities - `Assets/` — default app icons (Square44x44Logo, Square150x150Logo, etc.) - `winapp.yaml` — SDK version pinning for `restore`/`update` - `.winapp/` — downloaded SDK packages and generated projections - `.gitignore` update — excludes `.winapp/` and `devcert.pfx` +- `bindings/` — typed JS/TS WinRT projections (npm-only, Node / Electron) +- `package.json` update — adds the `winapp.jsBindings` namespace and `@microsoft/dynwinrt` dependency (npm-only) ### Restore after cloning @@ -259,15 +232,3 @@ Creates packaged layout, registers the Application, and launches the packaged ap | `--symbols` | Download symbols from Microsoft Symbol Server for richer native crash analysis. Only used with --debug-output. First run downloads symbols and caches them locally; subsequent runs use the cache. | (none) | | `--unregister-on-exit` | Unregister the development package after the application exits. Only removes packages registered in development mode. | (none) | | `--with-alias` | Launch the app using its execution alias instead of AUMID activation. The app runs in the current terminal with inherited stdin/stdout/stderr. Requires a uap5:ExecutionAlias in the manifest. Use "winapp manifest add-alias" to add an execution alias to the manifest. | (none) | - -### `winapp unregister` - -Unregisters a sideloaded development package. Only removes packages registered in development mode (e.g., via 'winapp run' or 'create-debug-identity'). - -#### Options - -| Option | Description | Default | -|--------|-------------|---------| -| `--force` | Skip the install-location directory check and unregister even if the package was registered from a different project tree | (none) | -| `--json` | Format output as JSON | (none) | -| `--manifest` | Path to the Package.appxmanifest (default: auto-detect from current directory) | (none) | diff --git a/.github/plugin/skills/winapp-cli/ui-automation/SKILL.md b/.github/plugin/skills/winapp-cli/ui-automation/SKILL.md index d512ddbb..93b2e237 100644 --- a/.github/plugin/skills/winapp-cli/ui-automation/SKILL.md +++ b/.github/plugin/skills/winapp-cli/ui-automation/SKILL.md @@ -183,7 +183,7 @@ The `--json` envelope for `ui inspect`, `ui get-focused`, `ui search`, and `ui w - `ui search --json` / `ui wait-for --json` may include an `invokableAncestor` field (element-shaped) on each match. - Per-element `id`, `parentSelector`, and `windowHandle` are **removed** — use `selector` as the public handle. -Full schemas with examples: `references/ui-json-envelope.md`. +Full schemas with examples: [`references/ui-json-envelope.md`](./references/ui-json-envelope.md). ## Related skills - `winapp-setup` for adding Windows SDK to your project diff --git a/docs/fragments/skills/winapp-cli/frameworks.md b/docs/fragments/skills/winapp-cli/frameworks.md index c17c994d..59898372 100644 --- a/docs/fragments/skills/winapp-cli/frameworks.md +++ b/docs/fragments/skills/winapp-cli/frameworks.md @@ -25,37 +25,35 @@ Use the **npm package** (`@Microsoft/WinAppCli`), **not** the standalone CLI. Th - The native winapp CLI binary bundled inside `node_modules` - A Node.js SDK with helpers for creating native C#/C++ addons - Electron-specific commands under `npx winapp node` -- An interactive yes/no bindings prompt during `npx winapp init` — `Add JS/TypeScript bindings to this project? [Y/n]:` — that opts your project into typed JS/TypeScript WinRT wrappers via dynwinrt (no native build required) +- Typed JS/TypeScript WinRT bindings via dynwinrt (no native build required), opt-in during `npx winapp init` or via `npx winapp node generate-bindings` Quick start: ```powershell npm install --save-dev @microsoft/winappcli -npx winapp init --use-defaults # init + JS bindings (yes) + C++ projections (always) -# (interactive: omit --use-defaults to get a Yes/No prompt for JS bindings; default is Yes) +npx winapp init --use-defaults # fresh init: scaffolds winapp.yaml + JS bindings + C++ projections +npx winapp node generate-bindings # existing project: add (or re-run) JS bindings only npx winapp node create-addon --template cs # create a C# native addon (for what dynwinrt can't drive — see below) npx winapp node add-electron-debug-identity # register identity for debugging ``` -JS/TS bindings (the `"winapp": { "jsBindings": {...} }` namespace in `package.json`) are **npm-only** — they require invocation via the `@microsoft/winappcli` npm package because they pull in `@microsoft/dynwinrt-codegen` as a transitive dep. The bindings prompt does not appear when running the standalone winget CLI. - #### Choosing between jsBindings and a native addon -The decision is almost entirely about the **shape of the API**, not preference. +The decision is about the **shape of the API**, not preference. -**Default: if the API is WinRT (ships in a `.winmd`), pick JS bindings at the `npx winapp init` prompt.** That covers nearly everything an Electron app actually calls on Windows — `Microsoft.Windows.*` (Notifications, FilePickers, Sensors, Storage, AI inference like `TextRecognizer` / `LanguageModel`), most of `Windows.*`, and all of `Microsoft.WindowsAppSDK.AI`. dynwinrt's [own scope statement](https://github.com/microsoft/dynwinrt#scope) sums it up as "non-UI WinRT APIs ... headless services from the Windows SDK and WinAppSDK". +**Default — WinRT API (ships in a `.winmd`) → JS bindings.** Covers most of `Microsoft.Windows.*` (Notifications, FilePickers, Sensors, AI like `TextRecognizer` / `LanguageModel`), `Windows.*`, and `Microsoft.WindowsAppSDK.AI`. See [dynwinrt scope](https://github.com/microsoft/dynwinrt#scope). -**Fall back to `node create-addon` when one of these is true:** +**Fall back to `node create-addon` when there's no `.winmd`:** -| Scenario | Template | Why dynwinrt can't help | -|---|---|---| -| The API is **Win32 / pure COM with no WinRT projection** (P/Invoke-style APIs, raw `IFileDialog`, registry, custom COM servers). | `--template cpp` | No `.winmd` exists, so there's nothing for the codegen to project. | -| You're integrating a **C++ library that ships only headers + a static/shared lib** (no `.winmd`). | `--template cpp` | Same — dynwinrt requires WinRT metadata. | -| You're integrating a **vendor SDK that only ships a managed .NET assembly** (no `.winmd`). | `--template cs` (uses [node-api-dotnet](https://github.com/microsoft/node-api-dotnet) under the hood). | Same — no WinRT projection to consume. | +| Scenario | Template | +|---|---| +| Win32 / pure COM (P/Invoke, raw `IFileDialog`, registry, custom COM servers) | `--template cpp` | +| C++ library (headers + static/shared lib only) | `--template cpp` | +| Managed .NET assembly only (vendor SDK) | `--template cs` ([node-api-dotnet](https://github.com/microsoft/node-api-dotnet)) | -It's normal to mix both in one app: jsBindings for the non-UI WinRT surface, a small C# or C++ addon for the one or two Win32 / non-WinRT calls that don't fit. +Mixing both in one app is normal. Additional Electron guides: -- [JS bindings reference](https://github.com/microsoft/WinAppCli/blob/main/docs/js-bindings.md) — full `winapp.jsBindings` JSON schema, per-package classification, lockfile +- [JS bindings guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/js-bindings.md) — full `winapp.jsBindings` JSON schema, per-package classification, lockfile - [Packaging guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/packaging.md) - [C++ notification addon guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/cpp-notification-addon.md) - [WinML addon guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/winml-addon.md) diff --git a/docs/fragments/skills/winapp-cli/setup.md b/docs/fragments/skills/winapp-cli/setup.md index 925caa0d..805ee027 100644 --- a/docs/fragments/skills/winapp-cli/setup.md +++ b/docs/fragments/skills/winapp-cli/setup.md @@ -47,41 +47,14 @@ winapp init --use-defaults --setup-sdks none winapp init --use-defaults --setup-sdks preview ``` -### Add JS/TS bindings for Node / Electron apps (npm only) - -When invoked via the `@microsoft/winappcli` npm package (i.e. `npx winapp init` -inside a Node / Electron project), `init` adds an interactive yes/no **bindings -prompt** — `Add JS/TypeScript bindings to this project? [Y/n]:`. Answering Yes -(the default) wires a default `"winapp": { "jsBindings": {} }` namespace into -`package.json` (covering the full Windows App SDK) and runs codegen as part of -init. C++ projections are always generated regardless of the answer. - -```powershell -# Interactive — prompted with the yes/no question. -npx winapp init - -# Non-interactive — auto-answers Yes (opts in to JS bindings). -npx winapp init --use-defaults - -# After editing winapp.jsBindings in package.json by hand (or pulling a -# teammate's package.json), regenerate bindings without re-prompting: -# Fast path — reuses the cached lockfile, no NuGet / cppwinrt re-run. -npx winapp node generate-bindings - -# Use the full restore instead if you also changed winapp.yaml (packages, -# sdkVersion, ...) — it refreshes the lockfile before re-running codegen. -npx winapp restore -``` - -Generated files land under `bindings/` and `@microsoft/dynwinrt` is -added to your `package.json` dependencies so production installs include it. - After `init`, your project will contain: - `Package.appxmanifest` — package identity and capabilities - `Assets/` — default app icons (Square44x44Logo, Square150x150Logo, etc.) - `winapp.yaml` — SDK version pinning for `restore`/`update` - `.winapp/` — downloaded SDK packages and generated projections - `.gitignore` update — excludes `.winapp/` and `devcert.pfx` +- `bindings/` — typed JS/TS WinRT projections (npm-only, Node / Electron) +- `package.json` update — adds the `winapp.jsBindings` namespace and `@microsoft/dynwinrt` dependency (npm-only) ### Restore after cloning diff --git a/docs/fragments/skills/winapp-cli/ui-automation.md b/docs/fragments/skills/winapp-cli/ui-automation.md index d5648199..950a8c98 100644 --- a/docs/fragments/skills/winapp-cli/ui-automation.md +++ b/docs/fragments/skills/winapp-cli/ui-automation.md @@ -178,7 +178,7 @@ The `--json` envelope for `ui inspect`, `ui get-focused`, `ui search`, and `ui w - `ui search --json` / `ui wait-for --json` may include an `invokableAncestor` field (element-shaped) on each match. - Per-element `id`, `parentSelector`, and `windowHandle` are **removed** — use `selector` as the public handle. -Full schemas with examples: `references/ui-json-envelope.md`. +Full schemas with examples: [`references/ui-json-envelope.md`](./references/ui-json-envelope.md). ## Related skills - `winapp-setup` for adding Windows SDK to your project diff --git a/docs/guides/electron/index.md b/docs/guides/electron/index.md index 16741897..dc4edaeb 100644 --- a/docs/guides/electron/index.md +++ b/docs/guides/electron/index.md @@ -38,13 +38,13 @@ First, you'll set up your development environment with the necessary tools and S Next, choose how to call Windows APIs from your Electron app: -#### Option A: [JS/TypeScript bindings via dynwinrt](../../js-bindings.md) ✨ *new* +#### Option A: [JS/TypeScript bindings via dynwinrt](js-bindings.md) ✨ *new* -The simplest path — typed JS/TypeScript wrappers generated from `.winmd` metadata, no native build step required from your Electron project. When you run `npx winapp init`, you'll be asked `Add JS/TypeScript bindings to this project? [Y/n]:` — answer **Y** (or pass `--use-defaults`) and a `bindings/` directory is dropped next to your sources. You `import { ChatClient } from './bindings'` and call WinRT directly. Bindings are typed at compile time but use `dynwinrt`'s libffi runtime to invoke methods at runtime, so no MSBuild / `node-gyp` step is involved. +The simplest path — typed JS/TypeScript wrappers generated from `.winmd` metadata, no native build step required from your Electron project. Opt in during `npx winapp init` (or pass `--use-defaults` to auto-accept) and a `bindings/` directory is dropped next to your sources. You `import { ChatClient } from './bindings'` and call WinRT directly. -[Add JS bindings →](../../js-bindings.md) +[Add JS bindings →](js-bindings.md) -> Native addons (Options B–D below) are still the right choice when you need C++/C# code paths — for instance, to encapsulate a stateful service or to use APIs `dynwinrt` doesn't yet drive (XAML / DispatcherQueue). For data-style WinRT APIs, jsBindings is the easier on-ramp. +> Native addons (Options B–D below) are still the right choice when the API has no WinRT projection — Win32 / pure COM (raw `IFileDialog`, registry, custom COM servers), C++ libraries that ship only headers + a static/shared lib, or vendor SDKs that ship only a managed .NET assembly. For everything that ships in a `.winmd`, jsBindings is the easier on-ramp. #### Option B: [Creating a C++ Notification Addon](cpp-notification-addon.md) Learn how to create a C++ addon that calls the Windows App SDK notification APIs. This is a great starting point for understanding native addons before diving into more complex scenarios. @@ -76,7 +76,7 @@ Finally, you'll package your app as an MSIX for distribution. This includes: | Phase | Guide | What You'll Learn | |-------|-------|-------------------| | 1️⃣ | [Setup](setup.md) | Install tools, initialize SDKs, configure build pipeline | -| 2️⃣ | [JS bindings (dynwinrt)](../../js-bindings.md) | Generate typed JS/TS WinRT wrappers, no native build step | +| 2️⃣ | [JS bindings (dynwinrt)](js-bindings.md) | Generate typed JS/TS WinRT wrappers, no native build step | | 2️⃣ | [C++ Notification Addon](cpp-notification-addon.md) | Create C++ addon, call notification APIs, test with debug identity | | 2️⃣ | [Phi Silica Addon](phi-silica-addon.md) | Create C# addon, call AI APIs, test with debug identity | | 2️⃣ | [WinML Addon](winml-addon.md) | Create C# addon, call WinML APIs, run ONNX models, integrate ML | diff --git a/docs/guides/electron/js-bindings.md b/docs/guides/electron/js-bindings.md new file mode 100644 index 00000000..8bcaeae6 --- /dev/null +++ b/docs/guides/electron/js-bindings.md @@ -0,0 +1,366 @@ + +# Calling WinRT APIs from JavaScript (JS / TypeScript bindings) + +This guide shows you how to call modern Windows Runtime (WinRT) APIs directly from your Electron app's JavaScript or TypeScript — **without** writing a C++ or C# native addon. `winapp` integrates the [`@microsoft/dynwinrt-codegen`](https://www.npmjs.com/package/@microsoft/dynwinrt-codegen) codegen, which produces typed JS + `.d.ts` bindings for WinAppSDK (and any other WinRT) APIs from their `.winmd` metadata. The generated bindings then use [`@microsoft/dynwinrt`](https://www.npmjs.com/package/@microsoft/dynwinrt) to access the underlying WinRT APIs directly at runtime. The result: full IntelliSense at compile time, no `node-gyp` / MSBuild step from your Electron project. + +> **When to choose JS bindings over a native addon:** when the API ships in a `.winmd` (most of `Windows.*` and `Microsoft.WindowsAppSDK.*`). Reach for a native addon only when there's no WinRT projection — Win32 / pure COM (raw `IFileDialog`, registry, custom COM servers), C++ libraries that ship only headers + a static/shared lib, or vendor SDKs that ship only a managed .NET assembly. See the C++ / C# addon guides for those cases. + +## Prerequisites + +Before starting this guide, make sure you've: +- Completed the [development environment setup](setup.md) +- Used `winapp` via `npx` (the `@microsoft/winappcli` npm package) — JS bindings only work through the npm shim; the standalone winget / installer build doesn't surface them. + +## Step 1: Add JS bindings to your project + +You have two paths depending on whether your Electron app already has a `winapp.yaml`. + +### Path A — Fresh project (init with bindings) + +Run `npx winapp init` and opt in to JS bindings (the interactive prompt defaults to Yes; pass `--use-defaults` to auto-accept in scripted / CI runs). `init` installs the WinAppSDK packages, adds a default `"winapp": { "jsBindings": {} }` namespace to `package.json` (covering the full Windows App SDK), and runs the codegen. + +```bash +npx winapp init --use-defaults +npm install # materializes the @microsoft/dynwinrt runtime dep +``` + +### Path B — Existing project (layer bindings on) + +If `winapp.yaml` already exists and you want to add JS bindings, run `generate-bindings`. The first invocation adds a default `winapp.jsBindings` namespace to `package.json` (covering the full Windows App SDK) and then generates immediately from the winmd lockfile written by your last `winapp restore`: + +```bash +npx winapp node generate-bindings +npm install # materializes the @microsoft/dynwinrt runtime dep +``` + +If you want to customize the scope before the first generation, you can still edit `package.json` directly — the empty form covers the full Windows App SDK: + +```jsonc +// package.json +{ + "winapp": { + "jsBindings": {} + } +} +``` + +…and then run `npx winapp node generate-bindings` (or `npx winapp restore` if you also need to refresh NuGet packages / the winmd lockfile). + +### What you get + +Both paths produce a `bindings/` directory next to your sources: + +``` +bindings/ +├── index.js # entry — re-exports every emitted class +├── index.d.ts # TS bundle +├── FileOpenPicker.js # one pair of files per emitted class +├── FileOpenPicker.d.ts +├── PickerLocationId.js +├── PickerLocationId.d.ts +└── … +``` + +To put them somewhere else, set `output` inside `winapp.jsBindings` in `package.json` (e.g. `"output": "src/generated/winrt"`) and re-run `restore`. + +> [!NOTE] +> If you need to slice generation by NuGet package, add your own `.winmd`, or cherry-pick a few classes from a giant vendor SDK, see [Common workflows](#common-workflows) and the [`package.json` schema](#packagejson--winappjsbindings-namespace) below. This walkthrough sticks to the simplest default-scope flow. + +## Step 2: Call a WinRT API from your Electron code + +Import from the generated `index.js` — you don't need to know which file inside `bindings/` a class lives in. Here's a native file picker (`Microsoft.Windows.Storage.Pickers.FileOpenPicker`) opened from your Electron main process. This API works on any Windows 11 machine once you've wired up debug identity in [Step 3](#step-3-run-it): + +```js +// src/index.js (Electron main) +const { app, BrowserWindow } = require('electron'); +const { + FileOpenPicker, + PickerLocationId, + PickerViewMode, +} = require('../bindings/index.js'); + +async function pickAnImage(mainWindow) { + // FileOpenPicker needs the parent window's HWND wrapped in a WindowId struct. + // Electron's getNativeWindowHandle() returns an 8-byte buffer on 64-bit Windows. + const hwnd = mainWindow.getNativeWindowHandle().readBigUInt64LE(0); + + const picker = FileOpenPicker.createInstance({ value: hwnd }); + picker.viewMode = PickerViewMode.Thumbnail; + picker.suggestedStartLocation = PickerLocationId.PicturesLibrary; + picker.fileTypeFilter.replaceAll(['.png', '.jpg', '.jpeg', '.gif']); + + const result = await picker.pickSingleFileAsync(); + return result?.path; // string with the chosen path, or undefined if the user cancelled +} + +// Usage (after `app.whenReady()`): +// const path = await pickAnImage(BrowserWindow.getFocusedWindow()); +// if (path) console.log('Picked:', path); +``` + +The same `bindings/index.js` re-exports every other emitted class — `AppNotificationManager`, `PowerManager`, `WidgetManager`, and so on. Import what you need; the codegen has already generated typed declarations for everything in your `winapp.jsBindings` scope. + +A few conventions to remember: + +- **Names are camelCase**, with a trailing underscore when they collide with JS keywords. WinRT `ViewMode` → `viewMode`; reserved words like `default`, `arguments`, `delete` are renamed `default_`, `arguments_`, `delete_`. +- **Construct via static factories, not `new`.** Use `FileOpenPicker.createInstance(windowId)`; WinRT constructor overloads are disambiguated with suffixed names like `createInstance(content)` / `createDefault()`. +- **`UInt64` / `Int64` struct fields and method parameters are typed `bigint`, not `number`.** Use `buffer.readBigUInt64LE(0)` to widen raw OS handles, and build struct values literally — `{ value: hwnd }` — when the WinRT side expects a `WindowId`-style wrapper. +- **Async methods return a `Promise`; pass an `AbortSignal` as the last argument for cancellation:** `await picker.pickSingleFileAsync(signal)`. Operations exposing progress return `WinRTAsyncWithProgress` — both `await`-able and exposing `op.progress(cb)` for streaming updates (long downloads, AI token streams). +- **Collections (`IVector_*`, `IMap_*`, `IVectorView_*`) come with JS-friendly helpers** alongside the raw WinRT API: `picker.fileTypeFilter.replaceAll(['*'])`, `vec.toArray()`, `for (const x of vec) …`, `vec.size`. + +Events follow an `on(handler)` shape that returns an unsubscribe function (`const off = obj.onSomething(cb); /* … */ off()`), and `IDisposable` WinRT objects should be wrapped in `try/finally` with a `.close()` call. + +You can call the same API from the renderer via `contextBridge` / `ipcRenderer` — exactly as you would for a native addon. The bindings have no dependency on Electron's main process; they work anywhere Node.js can `require()` them. + +## Step 3: Run it + +WinRT APIs that require an MSIX package identity (notifications, file pickers, …) need debug identity in development. See [Step 5 of the Electron setup guide](setup.md#step-5-understanding-debug-identity) for the full explanation; if you haven't already wired it up, the one-shot command is: + +```bash +npx winapp node add-electron-debug-identity +``` + +> [!NOTE] +> This is already part of the `postinstall` script added during setup, so it usually runs automatically on `npm install`. Re-run it manually whenever you change `Package.appxmanifest`, refresh app assets, or do a clean install. + +Now start the app: + +```bash +npm start +``` + +The first call to a WinRT method imported from `bindings/` will load `@microsoft/dynwinrt` and dispatch into the underlying WinRT API — transparent to your code. + +## Step 4 (optional): Regenerate after a metadata change + +The generated `bindings/` files are build artifacts — gitignore them, or commit for diff visibility, your call. Re-run codegen whenever you change `winapp.yaml` (`packages`, `sdkVersion`, …) or `winapp.jsBindings` in `package.json`: + +```bash +# Full restore — refreshes the lockfile (NuGet + cppwinrt headers) and re-runs codegen. +# Use whenever you change `winapp.yaml`. +npx winapp restore + +# Fast path — only re-runs dynwinrt-codegen against the cached lockfile. +# Use after editing only `winapp.jsBindings`. +npx winapp node generate-bindings +``` + +## `package.json` — `winapp.jsBindings` namespace + +> **Configuration lives in `package.json`, not `winapp.yaml`.** `winapp.yaml` is owned by the native CLI and only describes SDK package pins; the JS bindings schema lives under `"winapp": { "jsBindings": {...} }` in `package.json` — the same convention used by `eslint`, `jest`, `prettier`, `tsup`, etc. The native CLI has zero awareness of JS bindings. + +Full schema with every field shown explicitly: + +```jsonc +// package.json +{ + "name": "my-electron-app", + "version": "0.1.0", + "winapp": { + "jsBindings": { + // Output directory for generated .js + .d.ts (relative to workspace root). + "output": "bindings", + + // Extra .winmd files to feed into the codegen alongside the ones + // discovered from `winapp.yaml`'s NuGet packages. Two modes per entry: + // * winmdPath only → bulk-emit the whole winmd + // * + namespace + classes → cherry-pick: only emit the listed + // classes from that namespace (the winmd + // is loaded as ref-only so codegen can + // still resolve its other types). + // Paths: relative to workspace root, OR absolute. Missing files = warning. + "additionalWinmds": [ + { "winmdPath": "vendor/MyCompany.Foo.winmd" }, + { + "winmdPath": "vendor/BigVendor.SDK.winmd", + "namespace": "BigVendor.Camera", + "classes": ["Lens", "Sensor"] + } + ], + + // Extra .winmd files loaded for type resolution only (no emit). + // Use for shared dependency winmds your `additionalWinmds` entries + // reference but you don't want bindings for. + "additionalRefs": [ + "vendor/BigVendor.Common.winmd" + ] + } + } +} +``` + +### Field defaults at a glance + +| Field | Default | Type | +|-------|---------|------| +| `output` | `"bindings"` | string | +| `additionalWinmds` | `[]` | array of `{winmdPath, namespace?, classes?[]}` | +| `additionalRefs` | `[]` | array of paths | + +### Composition rules + +1. **NuGet packages** — every package installed via `winapp.yaml` is partitioned by the built-in policy (WinUI / WebView2 = skip; InteractiveExperiences = ref-only; everything else = bulk-emit). The policy isn't user-configurable; install fewer packages in `winapp.yaml` if you want fewer bindings. +2. **`additionalWinmds`** — each entry is either bulk-emitted (no `namespace`/`classes`) or cherry-picked (with both). Cherry-pick entries load the winmd as ref-only and only emit the listed classes. +3. **`additionalRefs`** — appended to the codegen `--ref` channel for type resolution; never emit. +4. **Codegen auto-classification** — `Windows.*` system winmds (and other foundation namespaces) are always loaded as resolution-only refs even when listed under `additionalWinmds` with no `namespace`/`classes`. Use the cherry-pick form (with `namespace` + `classes`) to pull individual classes out of them. + +## Common workflows + +### Generate bindings for the full WinAppSDK surface + +```jsonc +// package.json +{ + "winapp": { + "jsBindings": {} + } +} +``` + +The empty block accepts the defaults: `output: "bindings"`, and every package installed via `winapp.yaml` is bulk-emitted. + +> XAML namespaces (`Microsoft.UI.Xaml.*`, `Windows.UI.Xaml.*`) are out of scope for dynwinrt — the codegen itself classifies them automatically as resolution-only refs, so no JS gets emitted for them regardless of which packages are installed. + +### Add your own / a vendor `.winmd` + +```jsonc +{ + "winapp": { + "jsBindings": { + "additionalWinmds": [ + { "winmdPath": "vendor/MyCompany.Foo.winmd" }, // relative to workspace root + { "winmdPath": "C:/shared/OtherSdk.winmd" } // absolute also works + ] + } + } +} +``` + +The winmd is bulk-emitted: every public class inside gets a JS + `.d.ts` pair. + +### Cherry-pick a few classes from a giant vendor SDK + +```jsonc +{ + "winapp": { + "jsBindings": { + "additionalWinmds": [ + { + "winmdPath": "vendor/BigVendor.SDK.winmd", + "namespace": "BigVendor.Camera", + "classes": ["Lens", "Sensor"] + } + ] + } + } +} +``` + +The winmd is loaded for type resolution, but only `BigVendor.Camera.Lens` and `BigVendor.Camera.Sensor` get JS bindings emitted. The same pattern works for cherry-picking from system `Windows.*` winmds. + +### Override the output directory + +```jsonc +{ + "winapp": { + "jsBindings": { + "output": "src/generated/winrt" + } + } +} +``` + +## Runtime dependency injection + +When `init` (or `restore`) runs the JS-bindings step on a workspace, the CLI: + +1. Detects your project's package manager from the `packageManager` field in `package.json`, then falls back to lockfile sniffing (`pnpm-lock.yaml` → pnpm, `yarn.lock` → yarn, `bun.lockb` → bun, `package-lock.json` → npm). Defaults to **npm** if no signal exists. +2. Adds `@microsoft/dynwinrt` to your `package.json` `dependencies` (production dep, NOT devDep) — your generated bindings `import` from it at module load, so it must ship in your installed app. +3. Prints a PM-aware install hint (`npm install` / `pnpm install` / `yarn install` / `bun install`) so you know what to run next. + +Supported package managers: **npm, pnpm, yarn, bun**. + +> Why production not devDep? `@microsoft/dynwinrt` is the runtime that powers the generated bindings — without it, your generated `bindings/*.js` files fail to load at runtime. It's not a build-only tool. + +## How it works under the hood + +``` + ┌─────────────────────┐ ┌─────────────────────────────┐ + │ winapp.yaml │ │ package.json │ + │ (native CLI owns) │ │ "winapp": { "jsBindings" } │ + │ packages: ... │ │ (npm wrapper owns) │ + └──────────┬──────────┘ └──────────────┬──────────────┘ + │ │ + │ (winapp restore) │ (npm wrapper post-restore) + ▼ ▼ + ┌──────────────────────────────────────────┐ + │ WorkspaceSetupService (native) │ + │ • restore NuGet packages │ + │ • discover .winmd files │ + │ • write .winapp/winmds.lock.json │ + │ • generate cppwinrt projections │ + └──────────────────────────────────────────┘ + │ + ▼ (npm wrapper sees winapp.jsBindings in package.json) + ┌──────────────────────────────────────────┐ + │ JS bindings orchestrator (npm wrapper) │ + │ • partition winmds: emit / ref-only / │ + │ skip (per built-in winmd-policy) │ + │ • resolve additionalWinmds / │ + │ additionalRefs paths │ + │ • safety-check output dir │ + │ (.dynwinrt-managed marker) │ + │ • spawn @microsoft/dynwinrt-codegen │ + │ --winmd "p1;p2;..." --ref "r1;..." │ + │ • write .dynwinrt-managed marker │ + └──────────┬───────────────────────────────┘ + │ + ▼ + ┌──────────────────────────────────────────┐ + │ @microsoft/dynwinrt-codegen │ + │ • loads emit winmds + ref winmds │ + │ • auto-classifies Windows.* as │ + │ resolution-only refs │ + │ • generates .js + .d.ts │ → bindings/..{js,d.ts} + └──────────────────────────────────────────┘ + │ (at app runtime) + ▼ + ┌──────────────────────────────────────────┐ + │ @microsoft/dynwinrt │ (production dep injected into your package.json) + │ • dynamic WinRT invocation │ + │ • COM marshaling, async, delegates │ + └──────────────────────────────────────────┘ +``` + +### Per-package winmd categorization + +Some WinAppSDK packages ship `.winmd` files that dynwinrt cannot drive at runtime (XAML composables, UI Composition primitives). To keep the generated tree usable, winapp applies a **package-level policy** before handing winmds to the codegen: + +| Package | Category | Why | +|---------|----------|-----| +| `Microsoft.WindowsAppSDK.WinUI` | **Skip** | Pure XAML composables — `Button`, `Page`, `Application` etc. dynwinrt has no way to host. | +| `Microsoft.Web.WebView2` | **Skip** | Pulled in transitively by WinAppSDK (for the XAML `` control). The whole surface is HWND / Composition-hosted browser embedding — useless from a headless Node / Electron JS context (Electron already renders via Chromium). | +| `Microsoft.WindowsAppSDK.InteractiveExperiences` | **Ref-only** | Ships `Microsoft.UI.WindowId`, `Microsoft.Graphics.PointInt32`, `Microsoft.UI.Color` and other primitive types widely referenced by Foundation/Storage/Notifications APIs — must stay loaded for type resolution, but its own runtime classes are XAML/Composition types winapp cannot drive. | +| Everything else | **Emit** | Bulk-generate JS bindings (codegen still auto-classifies `Windows.*` as refs internally). | + +This split happens in the npm wrapper's `winmd-policy.ts` (`partitionByPackageCategory`). Skipped winmds aren't passed to the codegen at all; ref-only winmds flow through the codegen `--ref` channel. + +**Escape hatch**: if you need the contents of a Skip/Ref-only package (vendor fork, experimentation), list its winmd files explicitly under `winapp.jsBindings.additionalWinmds` — those flow through the user-additional channel and bypass the policy above. + +### The `.dynwinrt-managed` marker and `winmds.lock.json` + +After a successful generation winapp writes `/.dynwinrt-managed` into the output directory. Its presence is the **only** signal winapp uses to know that a non-empty output directory is safe to wipe before the next codegen run. **Never delete this file by hand**, and never put files you care about under the codegen output directory — the next `restore` will wipe anything other than the marker. (If the directory is non-empty and the marker is missing, winapp aborts rather than risk overwriting hand-written code.) + +In addition, `winapp restore` writes `.winapp/winmds.lock.json` — a human-readable snapshot of every NuGet package the restore resolved, with its version and the per-package winmd discovery results. The lockfile is the bridge between the native `winapp restore` (which writes it) and the npm wrapper (which reads it and applies the emit/refOnly/skip policy at codegen time). It's also a useful diagnostic artifact: + +- Useful to share when reporting bugs: it tells the maintainer exactly what got resolved without us needing to repro your NuGet feed setup. +- Records a SHA-256 of the top-level `packages:` block so you can spot yaml drift between restore runs. + +**Write atomicity**: lockfile writes go through a per-call `.tmp.` sibling that's renamed into place on completion, so concurrent readers always see a consistent (old or new) file, never a torn write. + +## Next steps + +- **CLI** — [`npx winapp init` reference](../../usage.md#init) and [`npx winapp restore` reference](../../usage.md#restore). +- **Runtime** — [`@microsoft/dynwinrt` on GitHub](https://github.com/microsoft/dynwinrt) — the runtime that powers the generated bindings. +- **Codegen** — [`@microsoft/dynwinrt-codegen` on GitHub](https://github.com/microsoft/dynwinrt) — the code-generation tool (same repo as `dynwinrt`). +- **Package & ship** — [Packaging Your App](packaging.md) once you're ready to produce an MSIX for distribution. diff --git a/docs/guides/electron/jsbindings.md b/docs/guides/electron/jsbindings.md deleted file mode 100644 index 1182cda0..00000000 --- a/docs/guides/electron/jsbindings.md +++ /dev/null @@ -1,184 +0,0 @@ - -# Calling WinRT APIs from JavaScript (JS / TypeScript bindings) - -This guide shows you how to call modern Windows Runtime (WinRT) APIs directly from your Electron app's JavaScript or TypeScript — **without** writing a C++ or C# native addon. `winapp` integrates the [`@microsoft/dynwinrt-codegen`](https://www.npmjs.com/package/@microsoft/dynwinrt-codegen) codegen, which produces typed JS + `.d.ts` bindings for WinAppSDK (and any other WinRT) APIs from their `.winmd` metadata. The generated bindings then use [`@microsoft/dynwinrt`](https://www.npmjs.com/package/@microsoft/dynwinrt) to access the underlying WinRT APIs directly at runtime. The result: full IntelliSense at compile time, no `node-gyp` / MSBuild step from your Electron project. - -> **When to choose JS bindings over a native addon:** when you only need to *call* WinRT APIs (load a model, run inference, send a notification, read a sensor) and don't need a stateful C++/C# service or APIs `dynwinrt` doesn't yet drive (XAML, DispatcherQueue). For data-style WinRT APIs, JS bindings are the easier on-ramp; for stateful or UI-hosting scenarios, see the C++ / C# addon guides. - -## Prerequisites - -Before starting this guide, make sure you've: -- Completed the [development environment setup](setup.md) -- Used `winapp` via `npx` (i.e., the `@microsoft/winappcli` npm package) — JS bindings are gated to npm-invoked `winapp` because the generator (`@microsoft/dynwinrt-codegen`) and runtime (`@microsoft/dynwinrt`) ship as npm dependencies. The standalone winget / installer build does not surface the bindings prompt and does not generate JS bindings. - -## Step 1: Add JS bindings to your project - -You have two paths depending on whether your Electron app already has a `winapp.yaml`. - -### Path A — Fresh project (init with bindings prompt) - -When you run `npx winapp init` for the first time, the CLI shows an interactive yes/no prompt asking whether to add JS bindings on top of the standard C++ projection workspace: - -```bash -npx winapp init -# > Add JS/TypeScript bindings to this project? [Y/n]: -``` - -Press **Enter** (default Yes) to opt in. `init` installs the WinAppSDK packages, generates the C++ projections (always), adds a default `"winapp": { "jsBindings": {} }` namespace to `package.json` (covering the full Windows App SDK), and runs the codegen. - -For a scripted / CI install, `--use-defaults` auto-opts in without prompting: - -```bash -npx winapp init --use-defaults -npm install # picks up the @microsoft/dynwinrt runtime dep that init injected -``` - -### Path B — Existing project (layer bindings on) - -If `winapp.yaml` already exists and you want to add JS bindings, run `generate-bindings`. The first invocation adds a default `winapp.jsBindings` namespace to `package.json` (covering the full Windows App SDK) and then generates immediately from the winmd lockfile written by your last `winapp restore`: - -```bash -npx winapp node generate-bindings -npm install # picks up the @microsoft/dynwinrt runtime dep -``` - -If you want to customize the scope before the first generation, you can still edit `package.json` directly — the empty form covers the full Windows App SDK: - -```jsonc -// package.json -{ - "winapp": { - "jsBindings": {} - } -} -``` - -…and then run `npx winapp node generate-bindings` (or `npx winapp restore` if you also need to refresh NuGet packages / the winmd lockfile). - -### What you get - -Both paths produce a `bindings/` directory next to your sources: - -``` -bindings/ -├── index.js # entry — re-exports every emitted class -├── index.d.ts # TS bundle -├── Microsoft.Windows.Vision.TextRecognizer.js -├── Microsoft.Windows.Vision.TextRecognizer.d.ts -├── Microsoft.Windows.AI.Generative.LanguageModel.js -├── Microsoft.Windows.AI.Generative.LanguageModel.d.ts -└── … # one pair of files per emitted class -``` - -To put them somewhere else, set `output` inside `winapp.jsBindings` in `package.json` (e.g. `"output": "src/generated/winrt"`) and re-run `restore`. - -> [!NOTE] -> If you need to slice generation by NuGet package, add your own `.winmd`, or cherry-pick a few classes from a giant vendor SDK, see the recipes in [JS / TypeScript bindings for WinRT](../../js-bindings.md). This guide sticks to the simplest default-scope flow. - -## Step 2: Call a WinRT API from your Electron code - -Import from the generated `index.js` — you don't need to know which file inside `bindings/` a class lives in. Here's an OCR (text recognition) flow as it would run in your Electron main process. We use `TextRecognizer` rather than `LanguageModel` because it doesn't require a Limited Access Feature token, so you can run this end-to-end on any Copilot+ PC without applying for access: - -```js -// src/index.js (Electron main) -const path = require('path'); -const { - TextRecognizer, - AIFeatureReadyState, -} = require('./bindings/index.js'); - -async function recognizeText(imagePath) { - // First-run model download (one time per user) — cheap no-op once cached. - if (TextRecognizer.getReadyState() !== AIFeatureReadyState.ready) { - await TextRecognizer.ensureReadyAsync(); - } - - const recognizer = await TextRecognizer.createAsync(); - try { - const recognized = await recognizer.recognizeTextFromImageAsync(imagePath); - return recognized.lines.map(line => ({ - text: line.text, - x: line.boundingBox.topLeft.x, - y: line.boundingBox.topLeft.y, - })); - } finally { - recognizer.close(); - } -} - -// Usage: -// const lines = await recognizeText(path.join(__dirname, 'screenshot.png')); -// lines.forEach(l => console.log(`(${l.x}, ${l.y}): ${l.text}`)); -``` - -For the full text-generation (Phi Silica `LanguageModel`) flow — which also lives in the same `bindings/` output — see the [Windows AI APIs reference](https://learn.microsoft.com/windows/ai/apis/). That surface requires a [Limited Access Feature token](https://learn.microsoft.com/windows/apps/develop/limited-access-features) before `LanguageModel.createAsync()` will succeed. - -A few conventions to remember: - -- **Method names are camelCase.** WinRT methods like `RecognizeTextFromImageAsync` become `recognizeTextFromImageAsync`; properties like `line.Text` become `line.text`. The codegen lowercases the first letter to match JavaScript style. -- **Structs use a `create()` factory, not `new`.** For example, `LanguageModelOptions.create()` — not `new LanguageModelOptions()`. -- **Async methods return a `progressOperation` thenable.** It's both `await`-able and exposes `op.progress(cb)` for streaming progress updates (e.g., `LanguageModel.generateResponseAsync` token streams). -- **Always `close()` IDisposable WinRT objects** in a `try/finally`. This frees the underlying COM resources promptly. -- **Pass `AbortSignal` for cancellation** when the underlying API supports it: `recognizer.recognizeTextFromImageAsync(imagePath, signal)`, `LanguageModel.createAsync(signal)`. Calling `controller.abort()` releases the awaiting Promise. - -You can call the same API from the renderer via `contextBridge` / `ipcRenderer` — exactly as you would for a native addon. The bindings have no dependency on Electron's main process; they work anywhere Node.js can `require()` them. - -## Step 3: Run it - -WinRT APIs that require an MSIX package identity (notifications, file pickers, …) need debug identity in development. See [Step 5 of the Electron setup guide](setup.md#step-5-understanding-debug-identity) for the full explanation; if you haven't already wired it up, the one-shot command is: - -```bash -npx winapp node add-electron-debug-identity -``` - -> [!NOTE] -> This is already part of the `postinstall` script added during setup, so it usually runs automatically on `npm install`. Re-run it manually whenever you change `Package.appxmanifest`, refresh app assets, or do a clean install. - -Now start the app: - -```bash -npm start -``` - -The first call to a WinRT method imported from `bindings/` will load `@microsoft/dynwinrt`, resolve the `.winmd` metadata, and invoke the COM method via libffi — all transparent to your code. - -## Step 4 (optional): Regenerate after a metadata change - -The generated `bindings/` files are committed-or-gitignored at your discretion (treat them like `package-lock.json` — generated, but stable enough to commit if you want diff visibility). Regenerate whenever: - -- You bump a WinAppSDK / WinRT package version in `winapp.yaml` -- You add or remove entries in `winapp.jsBindings.packages` / `additionalWinmds` / `extraTypes` (in `package.json`) -- The codegen itself is upgraded (`npm update @microsoft/dynwinrt-codegen`) - -In all cases, re-run codegen — it picks up the current `winapp.yaml` and `package.json` (neither file is mutated): - -```bash -# Fast path: only re-runs dynwinrt-codegen against the cached lockfile. -# Use this after editing only `winapp.jsBindings` in package.json. -npx winapp node generate-bindings - -# Full restore: also refreshes the lockfile (NuGet + cppwinrt headers). -# Use this whenever you change `winapp.yaml` (packages, sdkVersion, ...). -npx winapp restore -``` - -## Troubleshooting - -**`Cannot find module './bindings'`** -The generator hasn't produced output yet. Re-run `npx winapp restore` and verify `bindings/index.js` exists. - -**`MissingMethodException` / `Type not registered`** -A class your code imports is in a `.winmd` that isn't on the codegen's input. Check the `packages` list (or `additionalWinmds`) inside `winapp.jsBindings` in `package.json` — empty/omitted `packages` means "all installed packages participate", but if you've curated the list make sure the relevant package is there. - -**`HRESULT 0x8007XXXX` at call time** -The metadata was emitted but the OS implementation isn't available — usually a missing OS feature (e.g., a Windows AI API on a non-Copilot+ PC) or missing capability declaration in `Package.appxmanifest`. The exception message preserves the WinRT error string from the COM layer. - -**Bindings work in development but not after `electron-packager` / `electron-builder`** -Make sure `@microsoft/dynwinrt` is in your runtime `dependencies` (not just `devDependencies`) and that the packager's `asarUnpack` rules include the native binary. See [`packaging.md`](packaging.md) for the recommended config. - -## Next steps - -- **Reference** — [JS / TypeScript bindings for WinRT (`winapp.jsBindings`)](../../js-bindings.md) for the full `package.json` schema and advanced recipes (slice by package, cherry-pick types, ship a vendor `.winmd`). -- **CLI** — [`npx winapp init` reference](../../usage.md#init) and [`npx winapp restore` reference](../../usage.md#restore). -- **Runtime** — [`@microsoft/dynwinrt` on GitHub](https://github.com/microsoft/dynwinrt) for the libffi-based runtime that powers the generated bindings. -- **Package & ship** — [Packaging Your App](packaging.md) once you're ready to produce an MSIX for distribution. diff --git a/docs/js-bindings.md b/docs/js-bindings.md deleted file mode 100644 index aa97f0a6..00000000 --- a/docs/js-bindings.md +++ /dev/null @@ -1,388 +0,0 @@ -# JS / TypeScript bindings for WinRT (`winapp.jsBindings` feature) - -`winapp` can generate typed JavaScript + TypeScript wrappers for Windows Runtime APIs as part of the standard `init` / `restore` flow. The generator runs on top of [dynwinrt](https://github.com/microsoft/dynwinrt) — a runtime FFI bridge that calls WinRT methods via `.winmd` metadata, so the produced bindings are **typed at compile time** but call WinRT **dynamically at runtime** (no native build step required from your project). - -This document covers the user-facing CLI flow, the `package.json` schema, recipes for common scenarios, and a brief description of what happens under the hood. - -> **Availability** — JS/TS bindings are gated behind invocation via the `@microsoft/winappcli` npm package (i.e. `npx winapp …`). The interactive bindings prompt on `winapp init` only appears when invoked through the npm shim, because the binding generator (`@microsoft/dynwinrt-codegen`) and the runtime (`@microsoft/dynwinrt`) ship as npm dependencies. The standalone winget / installer build does not surface the prompt. - -> **Configuration lives in `package.json`, not `winapp.yaml`.** `winapp.yaml` is owned by the native CLI and only describes SDK package pins; the JS bindings schema lives under `"winapp": { "jsBindings": {...} }` in `package.json` — the same convention used by `eslint`, `jest`, `prettier`, `tsup`, etc. The native CLI has zero awareness of JS bindings. - ---- - -## Quick start - -The fastest path to "I want to call WinAppSDK / Windows Runtime APIs from my Node app": - -```bash -npm i -D @microsoft/winappcli -npx winapp init --use-defaults # auto-opts in to JS bindings -npm install # picks up the @microsoft/dynwinrt runtime dep that init injected -``` - -That gives you `bindings/*.js` + `*.d.ts` for the full Windows App SDK surface, ready to import: - -```ts -import { LanguageModel } from './bindings/Microsoft.Windows.AI.Generative.LanguageModel'; -const model = await LanguageModel.createAsync(); -``` - -Want the interactive prompt instead? Omit `--use-defaults`: - -```bash -npx winapp init -# > Add JS/TypeScript bindings to this project? [Y/n]: -``` - -Already have a workspace and just want to add bindings on top? Edit `package.json` to add a `winapp.jsBindings` namespace (an empty object means "full Windows App SDK surface — defaults"), then run `npx winapp restore`: - -```jsonc -// package.json -{ - "name": "my-electron-app", - "version": "0.1.0", - "winapp": { - "jsBindings": {} - } -} -``` - -```bash -npx winapp restore -``` - ---- - -## Common workflows - -> The JSON snippets below show only the fields each workflow touches. For the complete `winapp.jsBindings` schema (every field, default values, type, composition rules), see [`package.json` — `winapp.jsBindings` namespace](#packagejson--winappjsbindings-namespace). - -### 1. Generate bindings for the full WinAppSDK surface - -```jsonc -// package.json -{ - "winapp": { - "jsBindings": {} - } -} -``` - -The empty block accepts the defaults: `lang: "js"`, `output: "bindings"`, and `packages: []` which means **every installed package's `.winmd` files participate**. Convenient for exploration; for a shipping app you may want to narrow `packages` to just the APIs you actually call. - -> XAML namespaces (`Microsoft.UI.Xaml.*`, `Windows.UI.Xaml.*`) are out of scope for dynwinrt — the codegen itself classifies them automatically as resolution-only refs, so no JS gets emitted for them regardless of which packages are in scope. - -### 2. Slice generation by NuGet package - -When you don't want bindings for every installed package, list the NuGet package IDs you actually want bindings for: - -```jsonc -// package.json -{ - "winapp": { - "jsBindings": { - "output": "bindings", - "packages": [ - "Microsoft.WindowsAppSDK.AI", // AI APIs only - "Microsoft.WindowsAppSDK" // full WinAppSDK on top - ] - } - } -} -``` - -Each entry must match a NuGet package ID present under your `winapp.yaml` top-level `packages:` block. Empty / omitted means "all installed packages participate" (the default). - -> Earlier versions supported namespace-prefix slicing. Slicing now happens at the package level — coarser, but matches how WinRT metadata is actually shipped. - -### 3. Add your own / a vendor `.winmd` - -```jsonc -// package.json -{ - "winapp": { - "jsBindings": { - "output": "bindings", - "additionalWinmds": [ - "vendor/MyCompany.Foo.winmd", // relative to workspace root - "C:/shared/OtherSdk.winmd" // absolute also works - ] - } - } -} -``` - -`additionalWinmds` files are appended to the codegen input alongside the package-discovered winmds. Use this when you want bindings emitted for the entire vendor file. - -### 4. Cherry-pick a few classes from a giant vendor SDK - -```jsonc -{ - "winapp": { - "jsBindings": { - "output": "bindings", - "additionalRefs": [ // load for resolution only — NO bulk emit - "vendor/BigVendor.SDK.winmd" - ], - "extraTypes": [ // explicitly list classes to emit - { - "namespace": "BigVendor.Camera", - "classes": ["Lens", "Sensor"] - } - ] - } - } -} -``` - -This is the right pattern when the vendor ships a 200 MB winmd and you only want two classes. The codegen loads the metadata for type resolution but only emits bindings for `Lens` and `Sensor`. The same pattern works for cherry-picking from system `Windows.*` winmds, which the codegen always treats as refs. - -> If the same path appears in both `additionalWinmds` and `additionalRefs`, `additionalWinmds` wins (emission is the stronger intent). - -### 5. Override the output directory - -```jsonc -{ - "winapp": { - "jsBindings": { - "output": "src/generated/winrt" - } - } -} -``` - -### 6. Re-run codegen after editing `package.json` - -Any time you edit the `winapp.jsBindings` namespace (add a package, swap to a different scope, add an `extraTypes` entry), re-run codegen. The fast path skips the NuGet download + cppwinrt header regen and only re-invokes `dynwinrt-codegen`: - -```bash -npx winapp node generate-bindings -``` - -It reads the existing JSON without modifying it, replays the cached winmd inventory from `.winapp/winmds.lock.json`, and re-runs codegen — the output directory is replaced atomically (stage-then-swap; previous bindings are preserved on codegen failure). - -If you also changed `winapp.yaml` (`packages`, `sdkVersion`, …) the lockfile is stale, so run the full restore instead — it refreshes the lockfile **and** re-runs codegen in one step: - -```bash -npx winapp restore -``` - ---- - -## `package.json` — `winapp.jsBindings` namespace - -Full schema with every field shown explicitly: - -```jsonc -// package.json -{ - "name": "my-electron-app", - "version": "0.1.0", - "winapp": { - "jsBindings": { - // Target language — currently only "js" (emits both .js and .d.ts). - // "py" is supported in the underlying codegen but not yet exposed here. - "lang": "js", - - // Output directory for generated .js + .d.ts (relative to workspace root). - "output": "bindings", - - // NuGet package IDs to scope binding generation to. When non-empty, only - // .winmd files from these packages flow into the codegen (everything - // else under winapp.yaml's top-level `packages:` block is still - // installed for the C++ projections, just not turned into JS bindings). - // Each entry must match a package ID present in winapp.yaml's - // `packages:` block. When empty / omitted, every installed package - // participates. - "packages": [ - "Microsoft.WindowsAppSDK.AI" - ], - - // Extra .winmd files to feed into the codegen alongside package-discovered - // ones. Each entry is bulk-emitted (gets full bindings). - // Paths: relative to workspace root, OR absolute. Missing files = warning. - "additionalWinmds": [ - "vendor/MyCompany.Foo.winmd", - "C:/shared/OtherSdk.winmd" - ], - - // Like additionalWinmds, but LOAD-ONLY: the metadata is available for - // resolution (and for extraTypes lookups below) but no bulk emit happens. - // Pair with extraTypes to cherry-pick from large vendor SDKs. - "additionalRefs": [ - "vendor/BigVendor.SDK.winmd" - ], - - // Per-class explicit picks. Searches across all loaded winmds (package + - // additionalWinmds + additionalRefs + system Windows.*). Useful for grabbing - // one or two classes from a winmd you don't want fully emitted. - "extraTypes": [ - { - "namespace": "BigVendor.Camera", - "classes": ["Lens", "Sensor"] - } - ], - - // ── Per-package classification overrides ───────────────────────────── - // Layered on top of the built-in default policy (WinUI = skip, - // InteractiveExperiences = ref-only). Useful when MS introduces a new - // XAML package or you want to force-emit a normally-denylisted one. - - // Force-skip: drop entirely, no .js emit, not loaded as ref either. - "skipPackages": [ - "Some.New.WinUI.Package" - ], - - // Force-ref-only: load for type resolution (--ref channel) but no .js emit. - "refOnlyPackages": [ - "Vendor.PrimitiveTypes" - ], - - // Force-emit: overrides default skip / ref-only / user skip / user ref-only. - // Use to opt back in to a denylisted package for experimentation. - "emitPackages": [ - "Microsoft.WindowsAppSDK.WinUI" - ] - } - } -} -``` - -### Field defaults at a glance - -| Field | Default | Type | -|-------|---------|------| -| `lang` | `"js"` | string | -| `output` | `"bindings"` | string | -| `packages` | `[]` (= all installed packages) | array of NuGet IDs | -| `additionalWinmds` | `[]` | array of paths | -| `additionalRefs` | `[]` | array of paths | -| `extraTypes` | `[]` | array of `{namespace, classes[]}` | -| `skipPackages` | `[]` | array of NuGet IDs | -| `refOnlyPackages` | `[]` | array of NuGet IDs | -| `emitPackages` | `[]` | array of NuGet IDs | - -### Composition rules (when multiple lists overlap) - -The codegen applies these rules in order: - -1. **Package scope** — if `packages` is non-empty, only winmds inside those NuGet packages are taken from the package set; otherwise every installed package's winmds are taken. (`winapp.yaml`'s `packages:` block is the source of truth for what's installed; `winapp.jsBindings.packages` only filters which subset participates in JS-binding generation.) -2. **Per-package classification** — each in-scope package is classified into `emit` / `refOnly` / `skip` using the precedence:
**user `emitPackages` ⟶ default-skip ∪ user `skipPackages` ⟶ default-ref-only ∪ user `refOnlyPackages` ⟶ emit**.
Skip drops the winmd; ref-only routes it through `--ref`; emit produces JS bindings. -3. `additionalWinmds` and `additionalRefs` paths are appended to the codegen input. If a file is in both lists, `additionalWinmds` wins. -4. **Auto-classification by codegen** — `Windows.*` system winmds (and any other namespace the codegen treats as a foundation namespace) are loaded as resolution-only refs even when you list them under `additionalWinmds`. They will not produce JS files in bulk mode; use `extraTypes` to pull individual classes out. -5. `extraTypes` runs as a separate pass after the bulk pass — it can pull classes out of any loaded winmd (refs included). - ---- - -## Runtime dependency injection - -When `init` (or `restore`) runs the JS-bindings step on a workspace, the CLI: - -1. Detects your project's package manager from the `packageManager` field in `package.json`, then falls back to lockfile sniffing (`pnpm-lock.yaml` → pnpm, `yarn.lock` → yarn, `bun.lockb` → bun, `package-lock.json` → npm). Defaults to **npm** if no signal exists. -2. Adds `@microsoft/dynwinrt` to your `package.json` `dependencies` (production dep, NOT devDep) — your generated bindings `import` from it at module load, so it must ship in your installed app. -3. Prints a PM-aware install hint (`npm install` / `pnpm install` / `yarn install` / `bun install`) so you know what to run next. - -Supported package managers: **npm, pnpm, yarn, bun**. - -> Why production not devDep? `@microsoft/dynwinrt` provides the runtime FFI bridge — without it, your generated `bindings/*.js` files fail to load at runtime. It's not a build-only tool. - ---- - -## How it works under the hood - -``` - ┌─────────────────────┐ ┌─────────────────────────────┐ - │ winapp.yaml │ │ package.json │ - │ (native CLI owns) │ │ "winapp": { "jsBindings" } │ - │ packages: ... │ │ (npm wrapper owns) │ - └──────────┬──────────┘ └──────────────┬──────────────┘ - │ │ - │ (winapp restore) │ (npm wrapper post-restore) - ▼ ▼ - ┌──────────────────────────────────────────┐ - │ WorkspaceSetupService (native) │ - │ • restore NuGet packages │ - │ • discover .winmd files │ - │ • write .winapp/winmds.lock.json │ - │ • generate cppwinrt projections │ - └──────────────────────────────────────────┘ - │ - ▼ (npm wrapper sees winapp.jsBindings in package.json) - ┌──────────────────────────────────────────┐ - │ JS bindings orchestrator (npm wrapper) │ - │ • partition winmds: emit / ref-only / │ - │ skip (per built-in winmd-policy) │ - │ • resolve additionalWinmds / │ - │ additionalRefs paths │ - │ • safety-check output dir │ - │ (.dynwinrt-managed marker) │ - │ • spawn @microsoft/dynwinrt-codegen │ - │ --winmd "p1;p2;..." --ref "r1;..." │ - │ • write .dynwinrt-managed marker │ - └──────────┬───────────────────────────────┘ - │ - ▼ - ┌──────────────────────────────────────────┐ - │ @microsoft/dynwinrt-codegen │ - │ • loads emit winmds + ref winmds │ - │ • auto-classifies Windows.* as │ - │ resolution-only refs │ - │ • generates .js + .d.ts │ → bindings/..{js,d.ts} - └──────────────────────────────────────────┘ - │ (at app runtime) - ▼ - ┌──────────────────────────────────────────┐ - │ @microsoft/dynwinrt │ (production dep injected into your package.json) - │ • libffi-backed dynamic invocation │ - │ • COM marshaling, async, delegates │ - └──────────────────────────────────────────┘ -``` - -### Per-package winmd categorization - -Some WinAppSDK packages ship `.winmd` files that dynwinrt cannot drive at runtime (XAML composables, UI Composition, DispatcherQueue). To keep the generated tree usable, winapp applies a **package-level policy** before handing winmds to the codegen: - -| Package | Category | Why | -|---------|----------|-----| -| `Microsoft.WindowsAppSDK.WinUI` | **Skip** | Pure XAML composables — `Button`, `Page`, `Application` etc. dynwinrt has no way to host. | -| `Microsoft.WindowsAppSDK.InteractiveExperiences` | **Ref-only** | Ships `Microsoft.UI.WindowId`, `Microsoft.Graphics.PointInt32`, `Microsoft.UI.Color` and other primitive types widely referenced by Foundation/Storage/Notifications APIs — must stay loaded for type resolution, but its own runtime classes are XAML/Composition types winapp cannot drive. | -| Everything else | **Emit** | Bulk-generate JS bindings (codegen still auto-classifies `Windows.*` as refs internally). | - -This split happens in the npm wrapper's `winmd-policy.ts` (`partitionByPackageCategory`). Skipped winmds aren't passed to the codegen at all; ref-only winmds flow through the codegen `--ref` channel. - -**Escape hatch**: if you need the contents of a Skip/Ref-only package (vendor fork, experimentation), list its winmd files explicitly under `winapp.jsBindings.additionalWinmds` — those flow through the user-additional channel and bypass the policy above. - -### The `.dynwinrt-managed` marker and `winmds.lock.json` - -After a successful generation winapp writes `/.dynwinrt-managed` into the output directory. Its presence is the **only** signal winapp uses to know that a non-empty output directory is safe to wipe before the next codegen run. **Never delete this file by hand**, and never put files you care about under the codegen output directory — the next `restore` will wipe anything other than the marker. (If the directory is non-empty and the marker is missing, winapp aborts rather than risk overwriting hand-written code.) - -In addition, `winapp restore` writes `.winapp/winmds.lock.json` — a human-readable snapshot of every NuGet package the restore resolved, with its version and the per-package winmd discovery results. The lockfile is the bridge between the native `winapp restore` (which writes it) and the npm wrapper (which reads it and applies the emit/refOnly/skip policy at codegen time). It's also a useful diagnostic artifact: - -- Useful to share when reporting bugs: it tells the maintainer exactly what got resolved without us needing to repro your NuGet feed setup. -- Records a SHA-256 of the top-level `packages:` block so you can spot yaml drift between restore runs. - -**Write atomicity**: lockfile writes go through a per-call `.tmp.` sibling that's renamed into place on completion, so concurrent readers always see a consistent (old or new) file, never a torn write. - ---- - -## Troubleshooting - -| Symptom | Cause / fix | -|---------|-------------| -| `winapp init` doesn't show a bindings prompt | You ran the standalone `winapp` (winget / installer). JS bindings ship as an npm-only feature. Install via `npm i -D @microsoft/winappcli` and call as `npx winapp init` to get the prompt. | -| `bindings/` is empty after restore | Most likely your `packages` slice is too narrow, or matches no installed package. Check the debug log (`-v debug`) for the `winmd partition: emit=… ref-only=… skipped=…` line to see what got passed to the codegen. | -| Cannot find a class you expect | The codegen auto-classifies `Windows.*` (and similar foundation namespaces) as refs and does not bulk-emit them. Use `extraTypes` to pull individual classes out: `{ "namespace": "Windows.Foundation", "classes": ["Uri"] }`. | -| `winapp` refuses to write into the output directory | The output directory is non-empty and lacks a `.dynwinrt-managed` marker — winapp won't wipe it because it might contain hand-written code. Either point `output` somewhere else, or delete the directory yourself if you're sure. | -| Imports from `@microsoft/dynwinrt` fail at app runtime | Make sure you ran your package manager's install command after `init` / `restore` (so the auto-injected production dep actually downloads). The CLI prints the right command for your PM in the output. | -| Vendor winmd not found | `additionalWinmds` / `additionalRefs` paths are workspace-relative or absolute. Missing files print a warning and are skipped (so a stale entry doesn't break a working restore) — re-check the path. | -| Want bindings but already ran `init` without them | Run `npx winapp node generate-bindings` — it adds the default `"winapp": { "jsBindings": {} }` block on first use and generates immediately. (Requires a prior `winapp restore` so the winmd lockfile exists.) | -| `package.json not found` when adding bindings | Run `npm init -y` (or your package manager's equivalent) first so the file exists, then re-run `npx winapp init`. | - ---- - -## See also - -- [`@microsoft/dynwinrt`](https://github.com/microsoft/dynwinrt) — the runtime FFI bridge -- [`@microsoft/dynwinrt-codegen`](https://github.com/microsoft/dynwinrt) — the code-generation tool (lives in the same repo as `dynwinrt`) -- `winapp.yaml` schema reference (top-level, native-owned): only `packages:` -- `package.json` `winapp.jsBindings` namespace reference (npm-wrapper-owned): everything documented above diff --git a/docs/npm-usage.md b/docs/npm-usage.md index ae2abe1e..0cb5e0a1 100644 --- a/docs/npm-usage.md +++ b/docs/npm-usage.md @@ -1005,6 +1005,7 @@ Re-exported from Node.js for convenience. See [Node.js docs](https://nodejs.org/ | Property | Type | Required | Description | |----------|------|----------|-------------| | `exitOnError` | `boolean \| undefined` | No | | +| `cwd` | `string \| undefined` | No | Working directory for the spawned process (defaults to process.cwd()). | ### `CallWinappCliResult` diff --git a/docs/usage.md b/docs/usage.md index e28ae753..4d5d6aae 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -46,7 +46,7 @@ Add JS/TypeScript bindings to this project? [Y/n]: Picking **Yes** writes a default `"winapp.jsBindings"` namespace to `package.json` and runs `dynwinrt-codegen` to emit JS/TS wrappers. C++ projections (cppwinrt headers/libs/runtimes) are generated either way — there is no "JS only" mode; the JS bindings are an addition on top of the standard native workspace. Subsequent `winapp restore` calls re-run codegen against the pinned packages, or use `winapp node generate-bindings` for fast codegen-only re-runs after editing `winapp.jsBindings`. -See the [JS bindings reference](js-bindings.md) for the full schema (`packages`, `skip`, `refOnly`, `extraTypes`, etc.) and the [Electron JS bindings guide](guides/electron/jsbindings.md) for the end-to-end workflow. +See the [Electron JS bindings guide](guides/electron/js-bindings.md) for the full schema (`packages`, `skip`, `refOnly`, `extraTypes`, etc.) and the end-to-end workflow. **What it does:** @@ -182,7 +182,7 @@ JS/TS bindings are configured by declaring a `"winapp": { "jsBindings": {...} }` JS/TS bindings are **npm-only** — they require invocation via the `@microsoft/winappcli` npm package because they pull in `@microsoft/dynwinrt-codegen` as a transitive dep. The interactive bindings prompt during `init` only fires when invoked via the npm shim (`npx winapp …`); the standalone winget CLI does not surface it. -> See [JS bindings docs](js-bindings.md) for the full `winapp.jsBindings` schema, per-package winmd categorization (skip / refOnly / emit overrides), and the `winmds.lock.json` audit artifact. +> See [JS bindings guide](guides/electron/js-bindings.md) for the full `winapp.jsBindings` schema, per-package winmd categorization (skip / refOnly / emit overrides), and the `winmds.lock.json` audit artifact. --- diff --git a/scripts/generate-llm-docs.ps1 b/scripts/generate-llm-docs.ps1 index 48cd069d..6957a3a1 100644 --- a/scripts/generate-llm-docs.ps1 +++ b/scripts/generate-llm-docs.ps1 @@ -109,7 +109,7 @@ $SkillsDir = $SkillsPath # Skill → CLI command mapping for auto-generated options/arguments tables # Each skill maps to one or more CLI commands whose options/arguments should be included $SkillCommandMap = @{ - "setup" = @("init", "restore", "update", "run", "unregister") + "setup" = @("init", "restore", "update", "run") "package" = @("package", "create-external-catalog") "identity" = @("create-debug-identity") "signing" = @("cert generate", "cert install", "cert info", "sign") diff --git a/src/winapp-CLI/WinApp.Cli.Tests/BaseCommandTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/BaseCommandTests.cs index 6d6ef03d..aff40bf2 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/BaseCommandTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/BaseCommandTests.cs @@ -128,9 +128,15 @@ protected T GetRequiredService() where T : notnull return _serviceProvider.GetRequiredService(); } - // Make a NuGet package available in the test cache — copies from the real - // global cache if present, falls back to NuGet.org. Avoids HTTP timeouts - // when many parallel tests download large packages simultaneously. + /// + /// Ensures a single NuGet package is available in the test NuGet cache by copying it + /// from the real global NuGet cache if available, falling back to downloading from NuGet.org. + /// + /// This avoids expensive HTTP downloads that can timeout (100 s default) when many tests + /// run in parallel (12-way method-level parallelism) and all try to download large packages + /// like Microsoft.WindowsAppSDK.Runtime simultaneously. + /// + /// protected async Task EnsurePackageInTestCacheAsync(string packageId, string version, CancellationToken cancellationToken) { var nugetService = GetRequiredService(); @@ -142,7 +148,9 @@ protected async Task EnsurePackageInTestCacheAsync(string packageId, string vers return; } - // Try the real cache first — `dotnet build` populates it for free. + // Try to copy from the real NuGet cache (fast, no network needed). + // For EndToEndTests, 'dotnet build' already downloads packages here. + // For PackageCommandTests, previous test runs will have cached them. var realCachePath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nuget", "packages", packageId.ToLowerInvariant(), version); @@ -161,7 +169,9 @@ await packageInstallService.EnsurePackageAsync( version: version, cancellationToken: cancellationToken); } - // Recursively copies a directory and all its contents to a new location. + /// + /// Recursively copies a directory and all its contents to a new location. + /// private static void CopyDirectoryRecursive(DirectoryInfo source, DirectoryInfo target) { target.Create(); @@ -174,7 +184,9 @@ private static void CopyDirectoryRecursive(DirectoryInfo source, DirectoryInfo t } } - // Push default (Enter) answers for manifest prompts (packageName, publisherName, version, description) + /// + /// Push default (Enter) answers for manifest prompts (packageName, publisherName, version, description) + /// protected void DefaultAnswers() { TestAnsiConsole.Input.PushKey(ConsoleKey.Enter); diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InitCommandTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InitCommandTests.cs index 8c14a1e5..27b7055e 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InitCommandTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InitCommandTests.cs @@ -7,7 +7,9 @@ namespace WinApp.Cli.Tests; -// Tests for the InitCommand including SDK installation mode handling +/// +/// Tests for the InitCommand including SDK installation mode handling +/// [TestClass] public class InitCommandTests : BaseCommandTests { diff --git a/src/winapp-CLI/WinApp.Cli.Tests/WorkspaceSetupServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/WorkspaceSetupServiceTests.cs index 29d09342..32f7c6a5 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/WorkspaceSetupServiceTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/WorkspaceSetupServiceTests.cs @@ -184,18 +184,20 @@ public async Task SetupWorkspace_WithRequireExistingConfig_NoOpsWhenConfigMissin // Act var exitCode = await workspaceSetupService.SetupWorkspaceAsync(options, TestContext.CancellationToken); - // Restore on a non-.NET project without winapp.yaml is a no-op: - // nothing declared = nothing to restore. (.NET projects without yaml - // are rejected elsewhere.) + // Assert + // Restore on a non-.NET project with no winapp.yaml is a graceful no-op: + // a project that doesn't declare SDK package versions has nothing to restore. + // (.NET projects without yaml are still rejected — handled separately by the + // csproj-detection branch in SetupWorkspaceAsync.) Assert.AreEqual(0, exitCode, "Restore should be a no-op (exit 0) when no winapp.yaml exists on a non-.NET project"); } } - /// -/// End-to-end tests for the merged .NET / native workspace setup. Verifies the -/// unified WorkspaceSetupService handles both csproj and C++ projects through -/// the shared flow, including Windows App SDK Runtime install on .NET. +/// End-to-end tests for the merged .NET and native workspace setup code paths. +/// These tests verify that the unified WorkspaceSetupService correctly handles +/// both .NET (csproj) and native (C++) projects through the shared flow, +/// including the key fix: Windows App SDK Runtime installation on the .NET path. /// [TestClass] public class WorkspaceSetupServiceMergedPathTests : BaseCommandTests @@ -498,8 +500,11 @@ public async Task SetupWorkspace_DotNet_AttemptsRuntimeInstall() // (runtime install failure is non-blocking) Assert.AreEqual(0, exitCode, "Setup should complete despite runtime install not finding MSIX packages"); - // Verify the runtime install step was reached. (Pre-merge, .NET - // projects never hit this code path.) + // Verify the runtime install was ATTEMPTED by checking output for the + // runtime install step. This is the key behavioral change from the merge: + // before, .NET projects never reached this code path. + // Note: Non-error log messages go to static AnsiConsole, error logs to ConsoleStdErr, + // and Spectre status display goes to TestAnsiConsole var ansiOutput = TestAnsiConsole.Output; var logOutput = ConsoleStdErr.ToString(); var combinedOutput = ansiOutput + logOutput; diff --git a/src/winapp-CLI/WinApp.Cli/Commands/InitCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/InitCommand.cs index 1863319e..7c2b6b4b 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/InitCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/InitCommand.cs @@ -88,7 +88,7 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio UseDefaults = useDefaults, RequireExistingConfig = false, ForceLatestBuildTools = true, - ConfigOnly = configOnly, + ConfigOnly = configOnly }; return await workspaceSetupService.SetupWorkspaceAsync(options, cancellationToken); diff --git a/src/winapp-CLI/WinApp.Cli/Services/IConfigService.cs b/src/winapp-CLI/WinApp.Cli/Services/IConfigService.cs index c4295048..fd5ba248 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/IConfigService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/IConfigService.cs @@ -10,7 +10,5 @@ internal interface IConfigService FileInfo ConfigPath { get; set; } bool Exists(); WinappConfig Load(); - - // Full save. Drops comments / unknown fields. void Save(WinappConfig cfg); } diff --git a/src/winapp-CLI/WinApp.Cli/Services/IWorkspaceSetupService.cs b/src/winapp-CLI/WinApp.Cli/Services/IWorkspaceSetupService.cs index 6719f26b..308a7fb3 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/IWorkspaceSetupService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/IWorkspaceSetupService.cs @@ -7,7 +7,11 @@ namespace WinApp.Cli.Services; internal interface IWorkspaceSetupService { - // Finds the MSIX directory for Windows App SDK runtime packages + /// + /// Finds the MSIX directory for Windows App SDK runtime packages + /// + /// Optional dictionary of package versions to look for specific installed packages + /// The path to the MSIX directory, or null if not found public DirectoryInfo? FindWindowsAppSdkMsixDirectory(Dictionary? usedVersions = null); public Task SetupWorkspaceAsync(WorkspaceSetupOptions options, CancellationToken cancellationToken = default); public Task<(int InstalledCount, int ErrorCount)> InstallWindowsAppRuntimeAsync(DirectoryInfo msixDir, TaskContext taskContext, CancellationToken cancellationToken); diff --git a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Init.cs b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Init.cs index 7c6520ba..d00f332f 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Init.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Init.cs @@ -37,19 +37,10 @@ internal partial class WorkspaceSetupService ManifestGenerationInfo? manifestGenerationInfo = null; WinappConfig? config = null; - // Step 1: Handle configuration requirements - if (options.RequireExistingConfig && !configService.Exists()) - { - // Non-.NET project with no winapp.yaml — nothing to restore. - // (.NET projects without yaml are handled earlier in SetupWorkspaceAsync.) - // This is a no-op rather than an error: a project that doesn't declare - // SDK package versions in winapp.yaml has nothing for restore to do. - logger.LogInformation("{UISymbol} No winapp.yaml found in {ConfigDir}. Nothing to restore.", UiSymbols.Note, options.ConfigDir); - logger.LogInformation("If this project needs Windows SDK packages, run 'winapp init' to set them up."); - return (0, config, hadExistingConfig, shouldGenerateManifest, manifestGenerationInfo, shouldEnableDeveloperMode, recommendedTfm); - } - - // Step 2: Load or prepare configuration + // Step 1: Load or prepare configuration + // (The "RequireExistingConfig && no config exists" no-op case is handled + // earlier in SetupWorkspaceAsync — by the time we reach this method, + // either RequireExistingConfig is false or the config exists.) if (hadExistingConfig) { config = configService.Load(); diff --git a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.cs b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.cs index d6008b46..c7f1b079 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.cs @@ -65,6 +65,17 @@ public async Task SetupWorkspaceAsync(WorkspaceSetupOptions options, Cancel return 1; } + // Restore on a non-.NET project with no winapp.yaml — nothing to restore. + // (.NET projects without yaml are already rejected on line 61 above.) + // No-op success rather than error: a project that doesn't declare SDK + // package versions in winapp.yaml simply has nothing for restore to do. + if (options.RequireExistingConfig && !configService.Exists()) + { + logger.LogInformation("{UISymbol} No winapp.yaml found in {ConfigDir}. Nothing to restore.", UiSymbols.Note, options.ConfigDir); + logger.LogInformation("If this project needs Windows SDK packages, run 'winapp init' to set them up."); + return 0; + } + // Configuration / prompting phase bool hadExistingConfig; WinappConfig? config; diff --git a/src/winapp-npm/src/jsbindings/additional-winmds.ts b/src/winapp-npm/src/jsbindings/additional-winmds.ts index f30eb336..6d447c71 100644 --- a/src/winapp-npm/src/jsbindings/additional-winmds.ts +++ b/src/winapp-npm/src/jsbindings/additional-winmds.ts @@ -15,17 +15,39 @@ import * as fs from 'fs'; import * as path from 'path'; import { isNetworkPath, hasReparsePointOnPath } from './path-safety'; +/** + * One entry in `winapp.jsBindings.additionalWinmds` (in package.json). + * + * * `winmdPath` alone → bulk-emit the entire winmd + * * `winmdPath` + `namespace` + non-empty `classes` → cherry-pick: only + * emit the listed classes from the namespace (the winmd is loaded as + * ref-only so codegen can resolve its other types if needed). + */ +export interface AdditionalWinmd { + winmdPath: string; + namespace?: string; + classes?: string[]; +} + +/** An `AdditionalWinmd` whose `winmdPath` has been resolved + safety-checked. */ +export interface ResolvedAdditionalWinmd { + /** Absolute path to a real, on-disk, non-UNC, non-reparse winmd file. */ + winmdPath: string; + namespace?: string; + classes?: string[]; +} + export interface ResolveAdditionalWinmdsResult { - resolved: string[]; + resolved: ResolvedAdditionalWinmd[]; warnings: string[]; } export function resolveAdditionalWinmds( - entries: readonly string[] | undefined, + entries: readonly AdditionalWinmd[] | undefined, workspaceDir: string, fieldName: string ): ResolveAdditionalWinmdsResult { - const resolved: string[] = []; + const resolved: ResolvedAdditionalWinmd[] = []; const warnings: string[] = []; if (!entries || entries.length === 0) { return { resolved, warnings }; @@ -35,36 +57,27 @@ export function resolveAdditionalWinmds( const workspaceFull = path.resolve(workspaceDir).replace(/[\\/]+$/, ''); for (const entry of entries) { - if (typeof entry !== 'string' || !entry.trim()) { + if (!entry || typeof entry.winmdPath !== 'string' || !entry.winmdPath.trim()) { continue; } - const trimmed = entry.trim(); + const trimmed = entry.winmdPath.trim(); - // Reject UNC entries up-front (before any FS probe). if (isNetworkPath(trimmed)) { warnings.push( - `jsBindings.${fieldName} entry refused — network/UNC paths are not allowed (would probe attacker-controlled host). Entry: ${entry}` + `jsBindings.${fieldName} entry refused — network/UNC paths are not allowed (would probe attacker-controlled host). Entry: ${trimmed}` ); continue; } const fullPath = path.isAbsolute(trimmed) ? path.resolve(trimmed) : path.resolve(workspaceFull, trimmed); - // Re-check after resolve: a relative path under a UNC workspace - // resolves to a UNC. if (isNetworkPath(fullPath)) { warnings.push( - `jsBindings.${fieldName} entry resolved to UNC path; refusing to probe. Entry: ${entry} → ${fullPath}` + `jsBindings.${fieldName} entry resolved to UNC path; refusing to probe. Entry: ${trimmed} → ${fullPath}` ); continue; } - // Reparse-point guard. - // * Relative paths and absolute paths under the workspace → boundary = workspace. - // * Absolute paths outside the workspace → boundary = drive root. - // The user explicitly opted in to an out-of-workspace path (docs - // support absolute paths); we still walk every segment for reparse - // points, but don't force workspace containment. const sameAsWorkspace = fullPath.toLowerCase() === workspaceFull.toLowerCase(); const underWorkspace = sameAsWorkspace || fullPath.toLowerCase().startsWith((workspaceFull + path.sep).toLowerCase()); @@ -72,7 +85,7 @@ export function resolveAdditionalWinmds( if (hasReparsePointOnPath(fullPath, reparseBoundary)) { warnings.push( - `jsBindings.${fieldName} entry refused — file or one of its ancestors up to ${reparseBoundary} is a reparse point. Entry: ${entry} → ${fullPath}` + `jsBindings.${fieldName} entry refused — file or one of its ancestors up to ${reparseBoundary} is a reparse point. Entry: ${trimmed} → ${fullPath}` ); continue; } @@ -84,39 +97,29 @@ export function resolveAdditionalWinmds( seen.add(dedupeKey); if (!fs.existsSync(fullPath)) { - warnings.push(`jsBindings.${fieldName} entry not found, skipping: ${entry} (resolved to ${fullPath})`); + warnings.push(`jsBindings.${fieldName} entry not found, skipping: ${trimmed} (resolved to ${fullPath})`); continue; } - resolved.push(fullPath); - } + const ns = typeof entry.namespace === 'string' ? entry.namespace.trim() : ''; + const classes = Array.isArray(entry.classes) + ? entry.classes.map((c) => (typeof c === 'string' ? c.trim() : '')).filter((c) => c.length > 0) + : []; - return { resolved, warnings }; -} - -// Codegen extraTypes are silently skipped when malformed; count the valid -// entries for orchestration decisions (empty-emit + no valid extra types = -// nothing to do). -export function countValidExtraTypes(extraTypes: readonly JsBindingsExtraType[] | undefined): number { - if (!extraTypes) { - return 0; - } - let count = 0; - for (const et of extraTypes) { - if (et && et.namespace && et.namespace.trim() && et.classes && et.classes.length > 0) { - count++; + const out: ResolvedAdditionalWinmd = { winmdPath: fullPath }; + if (ns && classes.length > 0) { + out.namespace = ns; + out.classes = classes; } + resolved.push(out); } - return count; + + return { resolved, warnings }; } -// Shape of one `extraTypes` entry in the JS bindings configuration block. -// The canonical schema lives in package-json-config.ts (the -// `"winapp.jsBindings"` namespace inside package.json); the type lives here -// to break a circular import between package-json-config.ts and -// additional-winmds.ts (the latter is used by codegen-runner.ts to expand -// `additionalWinmds` paths). -export interface JsBindingsExtraType { - namespace: string; - classes: string[]; +/** True when the resolved entry is a cherry-pick (has both namespace + classes). */ +export function isCherryPick( + entry: ResolvedAdditionalWinmd +): entry is ResolvedAdditionalWinmd & { namespace: string; classes: string[] } { + return typeof entry.namespace === 'string' && Array.isArray(entry.classes) && entry.classes.length > 0; } diff --git a/src/winapp-npm/src/jsbindings/codegen-runner.ts b/src/winapp-npm/src/jsbindings/codegen-runner.ts index 33146ebe..c99d4db3 100644 --- a/src/winapp-npm/src/jsbindings/codegen-runner.ts +++ b/src/winapp-npm/src/jsbindings/codegen-runner.ts @@ -22,7 +22,6 @@ import * as os from 'os'; import * as crypto from 'crypto'; import { spawn } from 'child_process'; import { JsBindingsConfig } from './package-json-config'; -import { JsBindingsExtraType } from './additional-winmds'; import { assertSafeWorkspaceOutputDir, isNetworkPath, hasReparsePointOnPath } from './path-safety'; // Marker written into the output dir after a successful run; its presence @@ -31,12 +30,20 @@ export const MANAGED_MARKER_FILE_NAME = '.dynwinrt-managed'; const CODEGEN_PACKAGE_NAME = '@microsoft/dynwinrt-codegen'; +/** One cherry-pick pass derived from `additionalWinmds[i]` with namespace+classes. */ +export interface CodegenCherryPick { + namespace: string; + classes: readonly string[]; +} + export interface CodegenInputs { config: JsBindingsConfig; - /** Emit winmds (after winmd-policy filtering). */ + /** Emit winmds (after winmd-policy filtering + bulk additionalWinmds entries). */ emitWinmds: readonly string[]; /** Ref-only winmds (load for type resolution, don't generate bindings). */ refWinmds: readonly string[]; + /** Cherry-pick passes — each runs codegen once with `--namespace` + `--class-name` filters. */ + cherryPicks: readonly CodegenCherryPick[]; workspaceDir: string; /** A logger sink for stdout/stderr lines from the codegen child. */ log?: (line: string) => void; @@ -82,15 +89,15 @@ export async function runCodegen(inputs: CodegenInputs): Promise await runWithStaging(outputDir, async (stagingDir) => { if (emit.length > 0) { - const args = buildBulkArgs(prefixArgs, emit, stagingDir, inputs.config, refs); + const args = buildBulkArgs(prefixArgs, emit, stagingDir, refs); const stdout = await spawnCodegen(executable, args, inputs.workspaceDir, log, verbose); accumulateSummary(summary, parseSummary(stdout)); } - for (const et of inputs.config.extraTypes) { - if (!et.namespace.trim() || et.classes.length === 0) { + for (const cp of inputs.cherryPicks) { + if (!cp.namespace.trim() || cp.classes.length === 0) { continue; } - const args = buildExtraTypeArgs(prefixArgs, emit, stagingDir, inputs.config, refs, et); + const args = buildExtraTypeArgs(prefixArgs, emit, stagingDir, refs, cp); const stdout = await spawnCodegen(executable, args, inputs.workspaceDir, log, verbose); accumulateSummary(summary, parseSummary(stdout)); } @@ -242,7 +249,6 @@ export function buildBulkArgs( prefixArgs: readonly string[], emitWinmds: readonly string[], outputDir: string, - config: JsBindingsConfig, refWinmds: readonly string[] ): string[] { const args: string[] = [ @@ -253,14 +259,11 @@ export function buildBulkArgs( '--output', outputDir, '--lang', - config.lang, + 'js', ]; if (refWinmds.length > 0) { args.push('--ref', refWinmds.join(';')); } - if (config.lang === 'py') { - args.push('--pyi'); - } return args; } @@ -268,9 +271,8 @@ export function buildExtraTypeArgs( prefixArgs: readonly string[], emitWinmds: readonly string[], outputDir: string, - config: JsBindingsConfig, refWinmds: readonly string[], - extra: JsBindingsExtraType + extra: CodegenCherryPick ): string[] { const args: string[] = [...prefixArgs, 'generate']; if (emitWinmds.length > 0) { @@ -284,7 +286,7 @@ export function buildExtraTypeArgs( '--output', outputDir, '--lang', - config.lang + 'js' ); if (refWinmds.length > 0) { args.push('--ref', refWinmds.join(';')); diff --git a/src/winapp-npm/src/jsbindings/orchestrator.ts b/src/winapp-npm/src/jsbindings/orchestrator.ts index fe0c3ebe..cae12564 100644 --- a/src/winapp-npm/src/jsbindings/orchestrator.ts +++ b/src/winapp-npm/src/jsbindings/orchestrator.ts @@ -17,7 +17,7 @@ // so the cli.ts caller can decide whether to print anything. import * as path from 'path'; -import { readJsBindingsConfig, JsBindingsConfig } from './package-json-config'; +import { readJsBindingsConfig } from './package-json-config'; import { tryReadLockfile } from './lockfile-reader'; import { partitionPackageWinmds } from './winmd-policy'; import { resolveAdditionalWinmds } from './additional-winmds'; @@ -120,35 +120,47 @@ export async function runJsBindingsPipeline(options: OrchestratorOptions): Promi } } - // 3. Resolve user-supplied additional winmds (each independently). + // 3. Resolve user-supplied additional winmds + refs (path safety + dedupe). + // `additionalWinmds` entries can be bulk (winmdPath only) or cherry-pick + // (winmdPath + namespace + classes); we split them after resolution. const userEmit = resolveAdditionalWinmds(config.additionalWinmds, workspaceDir, 'additionalWinmds'); - const userRefs = resolveAdditionalWinmds(config.additionalRefs, workspaceDir, 'additionalRefs'); + const userRefs = resolveAdditionalWinmds( + (config.additionalRefs ?? []).map((p) => ({ winmdPath: p })), + workspaceDir, + 'additionalRefs' + ); for (const w of [...userEmit.warnings, ...userRefs.warnings]) { log(w); } - // 4. Partition NuGet winmds by category, using the lockfile's per-package - // grouping directly (no path-extraction guesswork). Per-package overrides - // from config are layered on top of the built-in skip / ref-only lists. - const partition = partitionPackageWinmds(lockfile.packages, { - overrides: { - skip: config.skipPackages, - refOnly: config.refOnlyPackages, - emit: config.emitPackages, - }, - emitScope: config.packages.length > 0 ? config.packages : undefined, - }); + // Split resolved additionalWinmds into bulk emit vs cherry-pick passes. + // Cherry-pick entries are loaded as ref-only so codegen can resolve types; + // only the listed classes are emitted. + const bulkAdditional: string[] = []; + const cherryPicks: { namespace: string; classes: string[] }[] = []; + const cherryPickRefs: string[] = []; + for (const entry of userEmit.resolved) { + if (entry.namespace && entry.classes && entry.classes.length > 0) { + cherryPicks.push({ namespace: entry.namespace, classes: entry.classes }); + cherryPickRefs.push(entry.winmdPath); + } else { + bulkAdditional.push(entry.winmdPath); + } + } + + // 4. Partition NuGet winmds by built-in package category (no user overrides). + const partition = partitionPackageWinmds(lockfile.packages); // 5. Compose final emit + ref sets. - const emitWinmds = [...partition.emit, ...userEmit.resolved]; - const refWinmds = [...partition.refOnly, ...userRefs.resolved]; + const emitWinmds = [...partition.emit, ...bulkAdditional]; + const refWinmds = [...partition.refOnly, ...userRefs.resolved.map((r) => r.winmdPath), ...cherryPickRefs]; - if (emitWinmds.length === 0 && countValidExtraTypes(config) === 0) { + if (emitWinmds.length === 0 && cherryPicks.length === 0) { return { outcome: 'noWinmdsToEmit', message: - 'No winmds matched the emit policy and no extraTypes are configured — nothing to generate. ' + - 'Add packages: entries (or wider scope) or extraTypes: in package.json `winapp.jsBindings`.', + 'No winmds matched the emit policy and no cherry-pick entries are configured — nothing to generate. ' + + 'Install more NuGet packages in `winapp.yaml`, or add `additionalWinmds` in `package.json` `winapp.jsBindings`.', }; } @@ -175,6 +187,7 @@ export async function runJsBindingsPipeline(options: OrchestratorOptions): Promi config, emitWinmds, refWinmds, + cherryPicks, workspaceDir, log, verbose: options.verbose, @@ -226,16 +239,6 @@ function formatCompletedMessage( return `Generated JS bindings → ${outputDir} (${parts.join(', ')})`; } -function countValidExtraTypes(config: JsBindingsConfig): number { - let count = 0; - for (const et of config.extraTypes) { - if (et.namespace && et.namespace.trim() && et.classes && et.classes.length > 0) { - count++; - } - } - return count; -} - function safeGetVersionPin(log: (line: string) => void): string | null { try { return getDynWinrtVersionPin(); diff --git a/src/winapp-npm/src/jsbindings/package-json-config.ts b/src/winapp-npm/src/jsbindings/package-json-config.ts index 786f92a6..786628fd 100644 --- a/src/winapp-npm/src/jsbindings/package-json-config.ts +++ b/src/winapp-npm/src/jsbindings/package-json-config.ts @@ -16,41 +16,24 @@ // (init, restore, package, ...) is identical regardless of whether the // user opted into JS bindings. -import { JsBindingsExtraType } from './additional-winmds'; +import { AdditionalWinmd } from './additional-winmds'; import { readPackageJsonDoc, mutatePackageJsonDoc, packageJsonExists } from './package-json-doc'; export interface JsBindingsConfig { - // Target language. Currently 'js' (default) or 'py'. - lang: string; // Output directory, relative to the workspace root. output: string; - // NuGet package IDs to scope binding generation to (empty = all in scope). - packages: string[]; - // Individual classes to generate alongside the bulk pass. - extraTypes: JsBindingsExtraType[]; - // Extra .winmd files to emit bindings for. - additionalWinmds: string[]; - // Extra .winmd files loaded for type resolution only. + // Extra .winmd files to feed into the codegen. Each entry either bulk-emits + // the whole winmd or cherry-picks individual classes from it. + additionalWinmds: AdditionalWinmd[]; + // Extra .winmd files loaded for type resolution only (no emit). additionalRefs: string[]; - // NuGet package IDs to drop entirely. - skipPackages: string[]; - // NuGet package IDs to load as --ref only. - refOnlyPackages: string[]; - // NuGet package IDs to force-emit, overriding skip / ref-only. - emitPackages: string[]; } export function defaultJsBindingsConfig(): JsBindingsConfig { return { - lang: 'js', output: 'bindings', - packages: [], - extraTypes: [], additionalWinmds: [], additionalRefs: [], - skipPackages: [], - refOnlyPackages: [], - emitPackages: [], }; } @@ -211,15 +194,9 @@ function coerceConfig(raw: unknown): JsBindingsConfig { const r = raw as Record; return { - lang: typeof r.lang === 'string' && r.lang.trim() ? r.lang.trim() : defaults.lang, output: typeof r.output === 'string' && r.output.trim() ? r.output.trim() : defaults.output, - packages: coerceStringArray(r.packages), - extraTypes: coerceExtraTypes(r.extraTypes), - additionalWinmds: coerceStringArray(r.additionalWinmds), + additionalWinmds: coerceAdditionalWinmds(r.additionalWinmds), additionalRefs: coerceStringArray(r.additionalRefs), - skipPackages: coerceStringArray(r.skipPackages), - refOnlyPackages: coerceStringArray(r.refOnlyPackages), - emitPackages: coerceStringArray(r.emitPackages), }; } @@ -239,21 +216,28 @@ function coerceStringArray(value: unknown): string[] { return out; } -function coerceExtraTypes(value: unknown): JsBindingsExtraType[] { +function coerceAdditionalWinmds(value: unknown): AdditionalWinmd[] { if (!Array.isArray(value)) { return []; } - const out: JsBindingsExtraType[] = []; + const out: AdditionalWinmd[] = []; for (const v of value) { if (!v || typeof v !== 'object') { continue; } const r = v as Record; + const winmdPath = typeof r.winmdPath === 'string' ? r.winmdPath.trim() : ''; + if (!winmdPath) { + continue; + } const ns = typeof r.namespace === 'string' ? r.namespace.trim() : ''; const classes = coerceStringArray(r.classes); - if (ns) { - out.push({ namespace: ns, classes }); + const entry: AdditionalWinmd = { winmdPath }; + if (ns && classes.length > 0) { + entry.namespace = ns; + entry.classes = classes; } + out.push(entry); } return out; } @@ -266,18 +250,16 @@ function coerceExtraTypes(value: unknown): JsBindingsExtraType[] { */ function serializeConfig(config: JsBindingsConfig): Record { return { - lang: config.lang, output: config.output, - packages: [...config.packages], - extraTypes: config.extraTypes.map((et) => ({ - namespace: et.namespace, - classes: [...et.classes], - })), - additionalWinmds: [...config.additionalWinmds], + additionalWinmds: config.additionalWinmds.map((w) => { + const entry: Record = { winmdPath: w.winmdPath }; + if (w.namespace && w.classes && w.classes.length > 0) { + entry.namespace = w.namespace; + entry.classes = [...w.classes]; + } + return entry; + }), additionalRefs: [...config.additionalRefs], - skipPackages: [...config.skipPackages], - refOnlyPackages: [...config.refOnlyPackages], - emitPackages: [...config.emitPackages], }; } diff --git a/src/winapp-npm/src/jsbindings/winmd-policy.ts b/src/winapp-npm/src/jsbindings/winmd-policy.ts index 61141741..ccba9887 100644 --- a/src/winapp-npm/src/jsbindings/winmd-policy.ts +++ b/src/winapp-npm/src/jsbindings/winmd-policy.ts @@ -19,40 +19,29 @@ const DEFAULT_REF_ONLY_PACKAGES = new Set( ['Microsoft.WindowsAppSDK.InteractiveExperiences'].map((p) => p.toLowerCase()) ); -const DEFAULT_SKIPPED_PACKAGES = new Set(['Microsoft.WindowsAppSDK.WinUI'].map((p) => p.toLowerCase())); - -export interface PackageCategoryOverrides { - skip?: string[]; - refOnly?: string[]; - emit?: string[]; -} - -function lowercaseSet(values: readonly string[] | undefined): Set | undefined { - if (!values || values.length === 0) { - return undefined; - } - return new Set(values.map((v) => v.toLowerCase())); -} +// Packages whose .winmd files are dropped entirely from the codegen input. +// These are pulled in transitively by Microsoft.WindowsAppSDK but expose UI / +// HWND / Composition surfaces that dynwinrt can't usefully drive from a +// headless Node process: +// - Microsoft.WindowsAppSDK.WinUI : XAML composables (Button, Page, ...) +// - Microsoft.Web.WebView2 : HWND / Composition-hosted browser +// Users who need a denylisted package back can list it under +// `winapp.jsBindings.emitPackages`. +const DEFAULT_SKIPPED_PACKAGES = new Set( + ['Microsoft.WindowsAppSDK.WinUI', 'Microsoft.Web.WebView2'].map((p) => p.toLowerCase()) +); // Categorize a single package ID. Precedence: -// force-emit > skip > refOnly > emit (default) -export function classifyPackage(packageId: string, overrides?: PackageCategoryOverrides): WinmdPackageCategory { +// skip > refOnly > emit (default) +export function classifyPackage(packageId: string): WinmdPackageCategory { if (!packageId || !packageId.trim()) { return 'emit'; } const id = packageId.toLowerCase(); - const skip = lowercaseSet(overrides?.skip); - const refOnly = lowercaseSet(overrides?.refOnly); - const forceEmit = lowercaseSet(overrides?.emit); - - // Force-emit always wins — lets users opt back in to a denylisted package. - if (forceEmit?.has(id)) { - return 'emit'; - } - if (DEFAULT_SKIPPED_PACKAGES.has(id) || skip?.has(id)) { + if (DEFAULT_SKIPPED_PACKAGES.has(id)) { return 'skip'; } - if (DEFAULT_REF_ONLY_PACKAGES.has(id) || refOnly?.has(id)) { + if (DEFAULT_REF_ONLY_PACKAGES.has(id)) { return 'refOnly'; } return 'emit'; @@ -109,23 +98,10 @@ export interface PackageWinmds { * package name directly (no path extraction needed — the lockfile already * groups winmds by package on the writer side). * - * `emitScope` (when provided) demotes out-of-scope emit packages to refOnly - * so codegen still sees their metadata for cross-package type resolution. - * Skip/refOnly classifications take precedence over scope. - * * Prefer this overload over `partitionByPackageCategory(string[], …)` when * the source data is the lockfile — see orchestrator.ts. */ -export function partitionPackageWinmds( - packages: readonly PackageWinmds[], - options?: { - overrides?: PackageCategoryOverrides; - emitScope?: readonly string[]; - } -): WinmdPartition { - const overrides = options?.overrides; - const scope = lowercaseSet(options?.emitScope); - +export function partitionPackageWinmds(packages: readonly PackageWinmds[]): WinmdPartition { const emit: string[] = []; const refOnly: string[] = []; const skipped: string[] = []; @@ -134,10 +110,7 @@ export function partitionPackageWinmds( if (!pkg || !pkg.name || !pkg.winmds || pkg.winmds.length === 0) { continue; } - let cat: WinmdPackageCategory = classifyPackage(pkg.name, overrides); - if (scope && cat === 'emit' && !scope.has(pkg.name.toLowerCase())) { - cat = 'refOnly'; - } + const cat: WinmdPackageCategory = classifyPackage(pkg.name); const bucket = cat === 'skip' ? skipped : cat === 'refOnly' ? refOnly : emit; for (const w of pkg.winmds) { bucket.push(w); @@ -149,23 +122,15 @@ export function partitionPackageWinmds( // Partition a flat list of winmd paths by category. Falls back to // `extractPackageIdFromPath` for each entry — needed for loose user-supplied -// `additionalWinmds` / `additionalRefs` that don't carry their package -// identity. For lockfile-sourced winmds, use `partitionPackageWinmds` instead. -// -// `emitScope` (when provided) demotes out-of-scope emit packages to refOnly -// so codegen still sees their metadata for cross-package type resolution. -// Skip/refOnly classifications take precedence over scope. +// winmds that don't carry their package identity. For lockfile-sourced +// winmds, use `partitionPackageWinmds` instead. export function partitionByPackageCategory( winmds: readonly string[], options?: { - overrides?: PackageCategoryOverrides; nugetCacheRoot?: string; - emitScope?: readonly string[]; } ): WinmdPartition { - const overrides = options?.overrides; const nugetCacheRoot = options?.nugetCacheRoot; - const scope = lowercaseSet(options?.emitScope); const emit: string[] = []; const refOnly: string[] = []; @@ -173,11 +138,7 @@ export function partitionByPackageCategory( for (const w of winmds) { const pkg = extractPackageIdFromPath(w, nugetCacheRoot); - let cat: WinmdPackageCategory = pkg === null ? 'emit' : classifyPackage(pkg, overrides); - - if (scope && cat === 'emit' && pkg !== null && !scope.has(pkg.toLowerCase())) { - cat = 'refOnly'; - } + const cat: WinmdPackageCategory = pkg === null ? 'emit' : classifyPackage(pkg); if (cat === 'skip') { skipped.push(w); From f29b2777d832ad68a2ed0bca4fb7c3baca548ca7 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Fri, 22 May 2026 11:20:42 +0800 Subject: [PATCH 15/27] clean code --- .gitignore | 3 - samples/electron/test.Tests.ps1 | 125 +++--------------- ...soft.Windows.SDK.BuildTools.WinApp.targets | 104 ++++----------- src/winapp-NuGet/tests/NuGet.Tests.ps1 | 63 --------- src/winapp-VSC/README.md | 2 - 5 files changed, 41 insertions(+), 256 deletions(-) diff --git a/.gitignore b/.gitignore index 376ff217..d962b820 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,6 @@ # Test working directory test-wd/ -# Local Claude / Copilot CLI agent permissions (per-machine, never committed) -.claude/ - # Logs logs *.log diff --git a/samples/electron/test.Tests.ps1 b/samples/electron/test.Tests.ps1 index 1981e3ef..eeae46f4 100644 --- a/samples/electron/test.Tests.ps1 +++ b/samples/electron/test.Tests.ps1 @@ -31,7 +31,6 @@ Describe "Electron Sample" { $script:sampleDir = $PSScriptRoot $script:tempDir = $null - $script:samplePhase2Dir = $null $script:appDir = $null $script:resolvedPkg = $null @@ -41,12 +40,11 @@ Describe "Electron Sample" { } AfterAll { + Set-Location $script:sampleDir + if (-not $SkipCleanup) { if ($script:tempDir) { Remove-TempTestDirectory -Path $script:tempDir } - # Phase 2 now runs against a temp copy of samples/electron, so the - # checked-in sample is never mutated and only the temp copy needs - # to be torn down — no git checkout / snapshot dance required. - if ($script:samplePhase2Dir) { Remove-TempTestDirectory -Path $script:samplePhase2Dir } + Remove-Item -Path (Join-Path $script:sampleDir 'node_modules') -Recurse -Force -ErrorAction SilentlyContinue } } @@ -227,9 +225,12 @@ Describe "Electron Sample" { } It "Should download the Electron binary" -Skip:$script:skip { - # Electron 42+ stopped auto-downloading on `npm install`; trigger - # it explicitly. install-electron is the v42 mechanism; exit code - # is ignored — the Should -Exist below is the real assertion. + # Electron 42+ no longer downloads its binary during `npm install` (see issue #524). + # Trigger the download explicitly so `add-electron-debug-identity` can find electron.exe. + # `install-electron` was added in Electron 42; older versions auto-download via + # postinstall, so the bin is absent and `npx --no-install` exits non-zero. Either + # outcome is fine as long as electron.exe ends up on disk — the Should -Exist below + # is the real assertion. Push-Location $script:appDir try { & npx --no-install install-electron 2>&1 | ForEach-Object { Write-Host $_ } @@ -283,124 +284,34 @@ Describe "Electron Sample" { } Context "Phase 2: Sample Build Check" { - BeforeAll { - if (-not $script:skip) { - # Phase 2 mutates package.json and writes generated artifacts - # (.winapp, bindings, node_modules, out). To keep the - # checked-in sample directory pristine for the contributor's - # working tree, run the entire phase against a temp copy. - # Exclude only the install/build outputs — keep the tracked - # package-lock.json so `npm ci` enforces the exact dependency - # graph contributors ship. - $script:samplePhase2Dir = New-TempTestDirectory -Prefix "electron-phase2" - $skipDirs = @('node_modules', '.winapp', 'bindings', 'out') - Get-ChildItem -Path $script:sampleDir -Force | Where-Object { - if ($_.PSIsContainer) { $skipDirs -notcontains $_.Name } - else { $true } - } | ForEach-Object { - Copy-Item -Path $_.FullName -Destination $script:samplePhase2Dir -Recurse -Force - } - } - } - It "Should install sample dependencies" -Skip:$script:skip { - Push-Location $script:samplePhase2Dir + Push-Location $script:sampleDir try { - # `npm ci` enforces the tracked package-lock.json so the - # exact dependency graph contributors ship is exercised - # (catches transitive regressions a relaxed `npm install` - # would mask). `--ignore-scripts` skips the sample's - # `postinstall` (`winapp restore && cert generate && - # setup-debug`) which would otherwise call the *published* - # winappcli pulled in via the devDependency pin — we - # override it with the local build below before running - # restore ourselves. - Invoke-Expression "npm ci --ignore-scripts" + Invoke-Expression "npm install --ignore-scripts" $LASTEXITCODE | Should -Be 0 } finally { Pop-Location } } It "Should have node_modules" -Skip:$script:skip { - Join-Path $script:samplePhase2Dir 'node_modules' | Should -Exist + Join-Path $script:sampleDir 'node_modules' | Should -Exist } It "Should have package.json" -Skip:$script:skip { - Join-Path $script:samplePhase2Dir 'package.json' | Should -Exist + Join-Path $script:sampleDir 'package.json' | Should -Exist } It "Should have forge.config.js" -Skip:$script:skip { - Join-Path $script:samplePhase2Dir 'forge.config.js' | Should -Exist + Join-Path $script:sampleDir 'forge.config.js' | Should -Exist } It "Should have appxmanifest.xml" -Skip:$script:skip { - Join-Path $script:samplePhase2Dir 'appxmanifest.xml' | Should -Exist - } - - It "Should install the locally-built winappcli on top" -Skip:$script:skip { - # Sample's devDependency pin would otherwise resolve `winapp` to - # the published version; we need the build under test. - Push-Location $script:samplePhase2Dir - try { - Install-WinappNpmPackage -PackagePath $script:resolvedPkg - Join-Path $script:samplePhase2Dir 'node_modules\.bin\winapp.cmd' | Should -Exist - } finally { Pop-Location } - } - - It "Should exercise the JS bindings flow via 'winapp restore'" -Skip:$script:skip { - # The sample ships `"winapp": { "jsBindings": {} }` in its - # package.json; restore must drive the npm-wrapper orchestrator - # end-to-end (winmd discovery → codegen → runtime-dep injection) - # so a regression to the bindings pipeline cannot silently - # survive Phase 2. - Push-Location $script:samplePhase2Dir - try { - Invoke-WinappCommand -Arguments "restore" - } finally { Pop-Location } + Join-Path $script:sampleDir 'appxmanifest.xml' | Should -Exist } - It "Should have generated bindings/ with the managed marker" -Skip:$script:skip { - $bindingsDir = Join-Path $script:samplePhase2Dir 'bindings' - $bindingsDir | Should -Exist - (Join-Path $bindingsDir '.dynwinrt-managed') | Should -Exist ` - -Because "restore on a workspace with winapp.jsBindings must populate the managed marker" - $jsCount = (Get-ChildItem -Path $bindingsDir -Filter '*.js' -ErrorAction SilentlyContinue).Count - $jsCount | Should -BeGreaterThan 50 -Because "Default jsBindings (full WinAppSDK) should generate many JS files" - } - - It "Should inject @microsoft/dynwinrt as a runtime dep in package.json" -Skip:$script:skip { - # Bindings import @microsoft/dynwinrt at load time — restore - # must auto-inject it as a production dep so `npm ci --omit=dev` - # doesn't strip it. - $pkgPath = Join-Path $script:samplePhase2Dir 'package.json' - $pkg = Get-Content $pkgPath -Raw | ConvertFrom-Json - $pkg.dependencies.'@microsoft/dynwinrt' | Should -Not -BeNullOrEmpty ` - -Because "restore on a workspace with winapp.jsBindings must inject the runtime dep" - } - - It "Should materialize @microsoft/dynwinrt under node_modules after a follow-up install" -Skip:$script:skip { - # The injector only mutates package.json; a follow-up `npm install` - # is what actually pulls the runtime down. Re-install with - # --ignore-scripts so we don't recurse through `winapp restore` - # again, then assert the package is on disk — guarantees the - # runtime is actually resolvable at app start, not just declared. - Push-Location $script:samplePhase2Dir - try { - Invoke-Expression "npm install --ignore-scripts" - $LASTEXITCODE | Should -Be 0 - } finally { Pop-Location } - Join-Path $script:samplePhase2Dir 'node_modules\@microsoft\dynwinrt' | Should -Exist ` - -Because "the runtime dep injected by restore must actually be installable" - } - - It "Should run the full sample build (build-all)" -Skip:$script:skip { - # `build-all = build-csAddon && build-addon` — the full build the - # sample's package, package-msix, and package-msix:x64 scripts - # depend on. Building only `build-csAddon` (the previous - # assertion) leaves the C++ side untested and let a node-gyp / - # node-addon-api regression survive Phase 2. - Push-Location $script:samplePhase2Dir + It "Should build the C# addon" -Skip:$script:skip { + Push-Location $script:sampleDir try { - Invoke-Expression "npm run build-all" + Invoke-Expression "npm run build-csAddon" $LASTEXITCODE | Should -Be 0 } finally { Pop-Location } } diff --git a/src/winapp-NuGet/build/Microsoft.Windows.SDK.BuildTools.WinApp.targets b/src/winapp-NuGet/build/Microsoft.Windows.SDK.BuildTools.WinApp.targets index a8756f2d..6c68ca58 100644 --- a/src/winapp-NuGet/build/Microsoft.Windows.SDK.BuildTools.WinApp.targets +++ b/src/winapp-NuGet/build/Microsoft.Windows.SDK.BuildTools.WinApp.targets @@ -85,15 +85,19 @@ Windows TFMs must build cleanly with no winapp activity. We prefer the explicitly-set $(TargetPlatformIdentifier) when present and fall back to deriving it from $(TargetFramework). - - $(WinAppManifestPath) is non-empty AND exists. Manifest existence - IS checked here at parse time so that build-time outputs (.appxrecipe, - manifest promotion, Content copy) get the gate right when the - manifest is already on disk. For build-time-generated manifests - (e.g. MAUI generates into $(OutputPath), or the MSIX SDK rewrites - promoted manifests during Build), the parse-time check evaluates - false; `_WinAppResolveManifestPath` re-evaluates this gate after - Build runs so `dotnet run` / `_WinAppValidateRunSupport` / - `_WinAppBuildRunArgs` see the live answer. + - A manifest exists at $(WinAppManifestPath). After the auto-detection + block above runs, this property points at the first existing manifest + across all supported output- and project-directory locations, OR at + whatever path the consumer set explicitly. Frameworks like MAUI + generate the manifest into $(OutputPath) based on platform / msbuild + props, so we deliberately accept output-dir paths here — without that, + MAUI head apps consuming this package transitively would never have + the gate activate. The other discriminators above (WindowsPackageType, + OutputType, TPI) already prevent activation in projects that aren't + intended to be packaged Windows apps, so a stale output-dir manifest + from a prior build only matters in a project that is otherwise still + a packaged Windows app — in which case re-activating the gate is the + correct behavior. ============================================================================ --> @@ -108,21 +112,11 @@ - - <_WinAppRunSupportShapeMatches Condition=" + <_WinAppRunSupportActive Condition=" '$(EnableWinAppRunSupport)' == 'true' And '$(WindowsPackageType)' != 'None' And '$(OutputType)' != 'Library' - And '$(_WinAppEffectiveTargetPlatformIdentifier)' == 'windows'">true - <_WinAppRunSupportShapeMatches Condition="'$(_WinAppRunSupportShapeMatches)' == ''">false - - <_WinAppRunSupportActive Condition=" - '$(_WinAppRunSupportShapeMatches)' == 'true' + And '$(_WinAppEffectiveTargetPlatformIdentifier)' == 'windows' And '$(WinAppManifestPath)' != '' And Exists('$(WinAppManifestPath)')">true <_WinAppRunSupportActive Condition="'$(_WinAppRunSupportActive)' == ''">false @@ -166,71 +160,20 @@ ============================================================================ _WinAppResolveManifestPath Target - Re-resolves $(WinAppManifestPath) and re-evaluates the master gate - $(_WinAppRunSupportActive) AFTER Build has run. The parse-time gate - can only see files that exist on disk before Build executes, which - is wrong for two scenarios this package needs to support: - - 1. appxmanifest.xml is promoted to AppxManifest and the MSIX SDK - generates the final $(OutputPath)AppxManifest.xml during Build - (placeholder resolution, PRI metadata, .appxrecipe stitching). - 2. Frameworks like MAUI generate the manifest into $(OutputPath) - from platform/msbuild props at Build time — the source tree - contains no manifest at all. - - For either case the parse-time auto-detection block can't have set - $(WinAppManifestPath), and even when it did the file may not exist - yet. This target re-runs the output-dir lookup with a fresh - `Exists(...)` and then re-derives $(_WinAppRunSupportActive) so every - downstream target (`_WinAppValidateRunSupport`, `_WinAppBuildRunArgs`, - `_WinAppPrepareRunArguments`, …) sees the live answer. - - Gated only on $(_WinAppRunSupportShapeMatches) — i.e. the project - discriminators (EnableWinAppRunSupport, WindowsPackageType, OutputType, - TPI) — and never on the parse-time master gate. That avoids the - chicken-and-egg where the parse-time gate freezes as false because - Build hasn't generated the manifest yet, which in turn skips this - target, which is the only thing that could unfreeze the gate. + When appxmanifest.xml is promoted to AppxManifest, the MSIX SDK generates + AppxManifest.xml in the output directory during build. This target re-resolves + WinAppManifestPath to point to the generated file (which has build metadata, + resolved placeholders, etc.) instead of the raw source file. ============================================================================ --> - - - $(OutputPath)AppxManifest.xml - $(OutputPath)Package.appxmanifest - $(OutputPath)appxmanifest.xml - $(MSBuildProjectDirectory)\AppxManifest.xml - $(MSBuildProjectDirectory)\Package.appxmanifest - $(MSBuildProjectDirectory)\appxmanifest.xml - - - + BeforeTargets="_WinAppValidateRunSupport;_WinAppBuildRunArgs" + Condition="'$(_WinAppRunSupportActive)' == 'true' And '$(_WinAppPromoteManifest)' == 'true'"> + $(OutputPath)AppxManifest.xml - - - - <_WinAppRunSupportActive Condition=" - '$(_WinAppRunSupportShapeMatches)' == 'true' - And '$(WinAppManifestPath)' != '' - And Exists('$(WinAppManifestPath)')">true - <_WinAppRunSupportActive Condition="'$(_WinAppRunSupportActive)' == ''">false - - - + - - - - - - - - - - -"@ - Set-Content -Path (Join-Path $dir "test.csproj") -Value $csproj - - Push-Location $dir - try { - & dotnet build (Join-Path $dir "test.csproj") -nologo 2>&1 | Out-Null - } finally { - Pop-Location - } - - $gateFile = Join-Path $dir "gate-value.txt" - $gateFile | Should -Exist -Because "_TestDumpGateValue must have fired after _WinAppResolveManifestPath" - (Get-Content $gateFile -Raw).Trim() | Should -Be 'true' ` - -Because "_WinAppResolveManifestPath must re-activate the gate once Build has produced the manifest" - } - } } Describe "Microsoft.Windows.SDK.BuildTools.WinApp package layout" -Skip:$script:skip { diff --git a/src/winapp-VSC/README.md b/src/winapp-VSC/README.md index 3205319a..9181f5a4 100644 --- a/src/winapp-VSC/README.md +++ b/src/winapp-VSC/README.md @@ -42,8 +42,6 @@ All commands are accessible from the Command Palette (`Ctrl+Shift+P`). Type **Wi | **WinApp: Run SDK Tool** | Run Windows SDK tools (`makeappx`, `signtool`, `mt`, `makepri`) with custom arguments. | | **WinApp: Get WinApp Path** | Show paths to installed SDK components. | -> **Note:** This extension exposes the native winapp CLI commands listed above. Node.js–specific subcommands provided by the [`@microsoft/winappcli` npm package](https://www.npmjs.com/package/@microsoft/winappcli) (`winapp node create-addon`, etc.) are intentionally not surfaced in the Command Palette — install and use the npm package directly for those workflows. - ### Integrated Debugging The extension provides a **custom `winapp` debug type** that launches your app with package identity and automatically attaches the appropriate debugger — all from a single **F5** press. From 33074e85d616807fc5901e5df812a4c9a4ded053 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Fri, 22 May 2026 14:54:01 +0800 Subject: [PATCH 16/27] remove unrelated code --- docs/npm-usage.md | 5 +-- src/winapp-npm/scripts/generate-docs.mjs | 46 ------------------------ src/winapp-npm/src/cli.ts | 4 +-- src/winapp-npm/src/winapp-cli-utils.ts | 6 ++-- 4 files changed, 5 insertions(+), 56 deletions(-) diff --git a/docs/npm-usage.md b/docs/npm-usage.md index 0cb5e0a1..ea15fbae 100644 --- a/docs/npm-usage.md +++ b/docs/npm-usage.md @@ -1,6 +1,4 @@ ---- -ms.custom: mslearn ---- + @@ -1005,7 +1003,6 @@ Re-exported from Node.js for convenience. See [Node.js docs](https://nodejs.org/ | Property | Type | Required | Description | |----------|------|----------|-------------| | `exitOnError` | `boolean \| undefined` | No | | -| `cwd` | `string \| undefined` | No | Working directory for the spawned process (defaults to process.cwd()). | ### `CallWinappCliResult` diff --git a/src/winapp-npm/scripts/generate-docs.mjs b/src/winapp-npm/scripts/generate-docs.mjs index 1a5065fa..b9d65f30 100644 --- a/src/winapp-npm/scripts/generate-docs.mjs +++ b/src/winapp-npm/scripts/generate-docs.mjs @@ -92,53 +92,7 @@ for (const sym of allExports) { // --------------------------------------------------------------------------- // Extraction helpers // --------------------------------------------------------------------------- - -// Strip JSDoc markers and return description text, stopping at the first -// `@tag` line. Mid-prose `@microsoft/...` references are preserved (TS's -// own `displayPartsToString` truncates at any `@`). -function rawDescFromJsDocText(rawCommentText) { - if (!rawCommentText) return ''; - let text = rawCommentText.trim(); - if (text.startsWith('/**')) text = text.slice(3); - if (text.endsWith('*/')) text = text.slice(0, -2); - - const lines = text.split(/\r?\n/); - const out = []; - for (const rawLine of lines) { - const line = rawLine.replace(/^\s*\*\s?/, ''); - // A JSDoc tag at start-of-line ends the description. - if (/^@\w/.test(line.trimStart()) && line.trimStart().startsWith('@')) { - break; - } - out.push(line); - } - return out.join(' ').replace(/\s+/g, ' ').trim(); -} - -// Read the leading /** */ block for a declaration directly from source, -// preserving `@microsoft/...` references that TS's doc API would truncate. -function getRawJsDoc(decl) { - if (!decl) return null; - const sourceFile = decl.getSourceFile(); - const sourceText = sourceFile.getFullText(); - const ranges = ts.getLeadingCommentRanges(sourceText, decl.getFullStart()); - if (!ranges || ranges.length === 0) return null; - for (let i = ranges.length - 1; i >= 0; i--) { - const r = ranges[i]; - const slice = sourceText.slice(r.pos, r.end); - if (slice.startsWith('/**')) return slice; - } - return null; -} - function getDoc(sym) { - // Prefer raw source extraction to keep mid-prose `@` sequences intact. - const decl = sym.valueDeclaration ?? sym.declarations?.[0]; - const raw = getRawJsDoc(decl); - if (raw) { - const txt = rawDescFromJsDocText(raw); - if (txt) return txt; - } return ts.displayPartsToString(sym.getDocumentationComment(checker)).trim(); } diff --git a/src/winapp-npm/src/cli.ts b/src/winapp-npm/src/cli.ts index 27838673..778f4afc 100644 --- a/src/winapp-npm/src/cli.ts +++ b/src/winapp-npm/src/cli.ts @@ -351,7 +351,7 @@ async function handleNode(args: string[]): Promise { break; default: - console.error(`Unknown node subcommand: ${subcommand}`); + console.error(`❌ Unknown node subcommand: ${subcommand}`); console.error(`Run "${CLI_NAME} node" for available subcommands.`); process.exit(1); } @@ -396,7 +396,7 @@ async function handleCreateAddon(args: string[]): Promise { // Validate template if (!['cpp', 'cs'].includes(options.template as string)) { - console.error(`Invalid template: ${options.template}. Valid options: cpp, cs`); + console.error(`❌ Invalid template: ${options.template}. Valid options: cpp, cs`); process.exit(1); } diff --git a/src/winapp-npm/src/winapp-cli-utils.ts b/src/winapp-npm/src/winapp-cli-utils.ts index f4eef7e8..d7607a33 100644 --- a/src/winapp-npm/src/winapp-cli-utils.ts +++ b/src/winapp-npm/src/winapp-cli-utils.ts @@ -7,8 +7,6 @@ export const WINAPP_CLI_CALLER_VALUE = 'nodejs-package'; export interface CallWinappCliOptions { exitOnError?: boolean; - /** Working directory for the spawned process (defaults to process.cwd()). */ - cwd?: string; } export interface CallWinappCliResult { @@ -51,13 +49,13 @@ export function getWinappCliPath(): string { * Always captures output and returns it along with the exit code */ export async function callWinappCli(args: string[], options: CallWinappCliOptions = {}): Promise { - const { exitOnError = false, cwd = process.cwd() } = options; + const { exitOnError = false } = options; const winappCliPath = getWinappCliPath(); return new Promise((resolve, reject) => { const child = spawn(winappCliPath, args, { stdio: 'inherit', - cwd, + cwd: process.cwd(), shell: false, env: { ...process.env, From d55c2b548026f0346283a395a1320f137ee2d301 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Fri, 22 May 2026 16:20:42 +0800 Subject: [PATCH 17/27] clean the code change --- .../Services/WorkspaceSetupService.Init.cs | 163 ---- .../Services/WorkspaceSetupService.Msix.cs | 298 ------- .../Services/WorkspaceSetupService.Options.cs | 20 - .../Services/WorkspaceSetupService.Prompts.cs | 240 ------ .../Services/WorkspaceSetupService.cs | 736 +++++++++++++++++- 5 files changed, 719 insertions(+), 738 deletions(-) delete mode 100644 src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Init.cs delete mode 100644 src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Msix.cs delete mode 100644 src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Options.cs delete mode 100644 src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Prompts.cs diff --git a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Init.cs b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Init.cs deleted file mode 100644 index d00f332f..00000000 --- a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Init.cs +++ /dev/null @@ -1,163 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using Microsoft.Extensions.Logging; -using Spectre.Console; -using WinApp.Cli.Helpers; -using WinApp.Cli.Models; - -namespace WinApp.Cli.Services; - -// WorkspaceSetupService — config-init slice. -// -// Owns the logic that decides whether we're "init" or "restore", loads or -// scaffolds winapp.yaml, walks the user through SDK / manifest / dev-mode -// prompts on first run, and validates a .NET project's TargetFramework. -// Result tuple is consumed by SetupWorkspaceAsync to decide the rest of -// the flow. -// -// Note: the npm-caller bindings prompt (add JS/TS bindings?) lives entirely -// in the @microsoft/winapp npm wrapper. The wrapper persists its decision -// in package.json (`"winapp": { "jsBindings": {...} }`) after this command -// returns; the native CLI has no awareness of JS bindings. -internal partial class WorkspaceSetupService -{ - private async Task<(int ReturnCode, WinappConfig? Config, bool HadExistingConfig, bool ShouldGenerateManifest, ManifestGenerationInfo? ManifestGenerationInfo, bool ShouldEnableDeveloperMode, string? RecommendedTfm)> InitializeConfigurationAsync(WorkspaceSetupOptions options, bool isDotNetProject, FileInfo? csprojFile, CancellationToken cancellationToken) - { - if (!options.RequireExistingConfig && !options.ConfigOnly && options.SdkInstallMode == null && options.UseDefaults) - { - // Default to Stable when --use-defaults - options.SdkInstallMode = SdkInstallMode.Stable; - } - - var hadExistingConfig = configService.Exists(); - bool shouldGenerateManifest = true; - bool shouldEnableDeveloperMode = false; - string? recommendedTfm = null; - ManifestGenerationInfo? manifestGenerationInfo = null; - WinappConfig? config = null; - - // Step 1: Load or prepare configuration - // (The "RequireExistingConfig && no config exists" no-op case is handled - // earlier in SetupWorkspaceAsync — by the time we reach this method, - // either RequireExistingConfig is false or the config exists.) - if (hadExistingConfig) - { - config = configService.Load(); - - if (config.Packages.Count == 0 && options.RequireExistingConfig) - { - logger.LogInformation("{UISymbol} winapp.yaml found but contains no packages. Nothing to restore.", UiSymbols.Note); - shouldEnableDeveloperMode = await AskShouldEnableDeveloperModeAsync(options, cancellationToken); - return (0, config, hadExistingConfig, shouldGenerateManifest, manifestGenerationInfo, shouldEnableDeveloperMode, recommendedTfm); - } - - var operation = options.RequireExistingConfig ? "Found" : "Found existing"; - logger.LogDebug("{UISymbol} {Operation} winapp.yaml with {PackageCount} packages", UiSymbols.Package, operation, config.Packages.Count); - - if (!options.RequireExistingConfig && config.Packages.Count > 0) - { - logger.LogDebug("{UISymbol} Using pinned package versions from winapp.yaml unless overridden.", UiSymbols.Note); - } - - // For setup command: ask about overwriting existing config (only if not skipping SDK installation and not config-only mode) - if (!options.RequireExistingConfig && !options.IgnoreConfig && !options.ConfigOnly && options.SdkInstallMode != SdkInstallMode.None && config.Packages.Count > 0) - { - if (options.UseDefaults) - { - options.IgnoreConfig = true; - } - else - { - var overwriteConfig = await ShowConfirmationPromptAsync(ansiConsole, "winapp.yaml exists with pinned versions. Overwrite?", cancellationToken); - shouldGenerateManifest = await AskShouldGenerateManifestAsync(options, cancellationToken); - if (shouldGenerateManifest) - { - manifestGenerationInfo = await PromptForManifestInfoAsync(options, cancellationToken); - } - if (!overwriteConfig) - { - options.IgnoreConfig = true; - } - else - { - await AskSdkInstallModeAsync(options, isDotNetProject, csprojFile, cancellationToken); - } - } - } - } - else - { - shouldGenerateManifest = await AskShouldGenerateManifestAsync(options, cancellationToken); - if (shouldGenerateManifest) - { - manifestGenerationInfo = await PromptForManifestInfoAsync(options, cancellationToken); - } - - await AskSdkInstallModeAsync(options, isDotNetProject, csprojFile, cancellationToken); - if (options.SdkInstallMode != SdkInstallMode.None) - { - config = new WinappConfig(); - logger.LogDebug("{UISymbol} No winapp.yaml found; will generate one after setup.", UiSymbols.New); - } - } - - // .NET: Validate TargetFramework (interactive) - if (isDotNetProject && csprojFile != null) - { - if (dotNetService.IsMultiTargeted(csprojFile)) - { - logger.LogError("The project '{CsprojFile}' uses multi-targeting (TargetFrameworks). winapp init does not support multi-targeted projects.", csprojFile.Name); - return (1, config, hadExistingConfig, shouldGenerateManifest, manifestGenerationInfo, shouldEnableDeveloperMode, recommendedTfm); - } - - var currentTfm = dotNetService.GetTargetFramework(csprojFile); - logger.LogDebug("Current TargetFramework: {Tfm}", currentTfm ?? "(not set)"); - - if (currentTfm == null || !dotNetService.IsTargetFrameworkSupported(currentTfm)) - { - recommendedTfm = dotNetService.GetRecommendedTargetFramework(currentTfm); - - if (!options.UseDefaults) - { - var currentDisplay = currentTfm ?? "(not set)"; - - var promptSuffix = options.SdkInstallMode != SdkInstallMode.None - ? " (Required for Windows App SDK)" - : ""; - - var shouldUpdate = await ShowConfirmationPromptAsync(ansiConsole, $"Update TargetFramework to \"{recommendedTfm}\"{promptSuffix}?", cancellationToken); - - if (!shouldUpdate) - { - if (options.SdkInstallMode != SdkInstallMode.None) - { - logger.LogError("TargetFramework '{Tfm}' is not supported for Windows App SDK. Cannot continue.", currentDisplay); - return (1, config, hadExistingConfig, shouldGenerateManifest, manifestGenerationInfo, shouldEnableDeveloperMode, recommendedTfm); - } - - // Not installing SDKs, so TFM update is not required — skip it - recommendedTfm = null; - } - } - else - { - var currentDisplay = currentTfm ?? "(not set)"; - logger.LogWarning( - "TargetFramework '{CurrentTfm}' is not supported for Windows App SDK. Automatically updating to '{RecommendedTfm}' because --use-defaults was specified.", - currentDisplay, - recommendedTfm); - logger.LogInformation("Automatically updating TargetFramework from {CurrentTfm} to {RecommendedTfm} because --use-defaults was specified.", Markup.Escape(currentDisplay), recommendedTfm); - } - } - else - { - logger.LogDebug("{UISymbol} TargetFramework '{Tfm}' is supported", UiSymbols.Check, currentTfm); - } - } - - shouldEnableDeveloperMode = await AskShouldEnableDeveloperModeAsync(options, cancellationToken); - - return (0, config, hadExistingConfig, shouldGenerateManifest, manifestGenerationInfo, shouldEnableDeveloperMode, recommendedTfm); - } -} diff --git a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Msix.cs b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Msix.cs deleted file mode 100644 index 2534f5ff..00000000 --- a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Msix.cs +++ /dev/null @@ -1,298 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using System.Runtime.InteropServices; -using WinApp.Cli.ConsoleTasks; -using WinApp.Cli.Helpers; - -namespace WinApp.Cli.Services; - -// WorkspaceSetupService — MSIX runtime install slice. -// -// Reads the per-architecture MSIX inventory from the NuGet cache, resolves -// each package's real identity from its AppxManifest.xml, and installs -// Windows App SDK runtime packages (skipping already-installed-equal-or-newer -// ones). Also owns NuGet-cache MSIX directory lookup. -internal partial class WorkspaceSetupService -{ - // Package entry information from MSIX inventory - public class MsixPackageEntry - { - public required string FileName { get; set; } - public required string PackageIdentity { get; set; } - } - - // Parses the MSIX inventory file and returns package entries (shared implementation) - public static async Task?> ParseMsixInventoryAsync(TaskContext taskContext, DirectoryInfo msixDir, CancellationToken cancellationToken) - { - var architecture = GetSystemArchitecture(); - - taskContext.AddDebugMessage($"{UiSymbols.Note} Detected system architecture: {architecture}"); - - // Look for MSIX packages for the current architecture - var msixArchDir = Path.Combine(msixDir.FullName, $"win10-{architecture}"); - if (!Directory.Exists(msixArchDir)) - { - taskContext.AddDebugMessage($"{UiSymbols.Note} No MSIX packages found for architecture {architecture}"); - taskContext.AddDebugMessage($"{UiSymbols.Note} Available directories: {string.Join(", ", msixDir.GetDirectories().Select(d => d.Name))}"); - return null; - } - - // Read the MSIX inventory file - var inventoryPath = Path.Combine(msixArchDir, "msix.inventory"); - if (!File.Exists(inventoryPath)) - { - taskContext.AddDebugMessage($"{UiSymbols.Note} No msix.inventory file found in {msixArchDir}"); - return null; - } - - var inventoryLines = await File.ReadAllLinesAsync(inventoryPath, cancellationToken); - var packageEntries = inventoryLines - .Where(line => !string.IsNullOrWhiteSpace(line) && line.Contains('=')) - .Select(line => line.Split('=', 2)) - .Where(parts => parts.Length == 2) - .Select(parts => new MsixPackageEntry { FileName = parts[0].Trim(), PackageIdentity = parts[1].Trim() }) - .ToList(); - - if (packageEntries.Count == 0) - { - taskContext.AddDebugMessage($"{UiSymbols.Note} No valid package entries found in msix.inventory"); - return null; - } - - taskContext.AddDebugMessage($"{UiSymbols.Package} Found {packageEntries.Count} MSIX packages in inventory"); - - return packageEntries; - } - - // Reads the actual package Name and Version from the AppxManifest.xml inside an MSIX file. - // The MSIX inventory file can have incorrect package names (e.g., the DDLM), so we read - // the real identity directly from the package to ensure correct installation checks. - private static (string? Name, string? Version) ReadMsixIdentity(string msixFilePath, TaskContext taskContext) - { - try - { - using var zip = System.IO.Compression.ZipFile.OpenRead(msixFilePath); - var manifestEntry = zip.GetEntry("AppxManifest.xml"); - if (manifestEntry == null) - { - return (null, null); - } - - using var stream = manifestEntry.Open(); - var manifest = AppxManifestDocument.Load(stream); - return (manifest.IdentityName, manifest.IdentityVersion); - } - catch (Exception ex) - { - taskContext.AddDebugMessage($"{UiSymbols.Note} Could not read identity from {Path.GetFileName(msixFilePath)}: {ex.Message}"); - return (null, null); - } - } - - // Installs Windows App SDK runtime MSIX packages for the current system architecture - public async Task<(int InstalledCount, int ErrorCount)> InstallWindowsAppRuntimeAsync(DirectoryInfo msixDir, TaskContext taskContext, CancellationToken cancellationToken) - { - var architecture = GetSystemArchitecture(); - - // Get package entries from MSIX inventory - var packageEntries = await ParseMsixInventoryAsync(taskContext, msixDir, cancellationToken); - if (packageEntries == null || packageEntries.Count == 0) - { - return (0, 0); - } - - var msixArchDir = Path.Combine(msixDir.FullName, $"win10-{architecture}"); - - // Build list of packages to evaluate - var packagesToCheck = new List<(string FilePath, string PackageName, string NewVersion, string FileName)>(); - foreach (var entry in packageEntries) - { - var msixFilePath = Path.Combine(msixArchDir, entry.FileName); - if (!File.Exists(msixFilePath)) - { - taskContext.AddDebugMessage($"{UiSymbols.Note} MSIX file not found: {msixFilePath}"); - continue; - } - - // Read the actual package identity from the MSIX's AppxManifest.xml. - // The inventory file's PackageIdentity can differ from the real installed name. - var (packageName, newVersionString) = ReadMsixIdentity(msixFilePath, taskContext); - if (packageName == null) - { - // Fallback: parse from inventory identity string - var identityParts = entry.PackageIdentity.Split('_'); - packageName = identityParts[0]; - newVersionString = identityParts.Length >= 2 ? identityParts[1] : ""; - } - - packagesToCheck.Add((msixFilePath, packageName, newVersionString ?? "", entry.FileName)); - } - - if (packagesToCheck.Count == 0) - { - return (0, 0); - } - - taskContext.AddDebugMessage($"{UiSymbols.Info} Checking and installing {packagesToCheck.Count} MSIX packages"); - - var installedCount = 0; - var errorCount = 0; - - foreach (var (filePath, packageName, newVersion, fileName) in packagesToCheck) - { - // Check if already installed with same or newer version - var installedVersion = packageRegistrationService.GetInstalledVersion(packageName); - if (installedVersion != null) - { - if (Version.TryParse(installedVersion, out var existing) && - Version.TryParse(newVersion, out var incoming) && - existing >= incoming) - { - taskContext.AddDebugMessage($"{UiSymbols.Check} {fileName}: Already installed or newer version exists"); - continue; - } - } - - taskContext.AddDebugMessage($"{UiSymbols.Info} {fileName}: Will install"); - - try - { - await packageRegistrationService.InstallPackageAsync(filePath, cancellationToken); - installedCount++; - taskContext.AddDebugMessage($"{UiSymbols.Check} {fileName}: Installation successful"); - } - catch (Exception ex) - { - errorCount++; - taskContext.AddDebugMessage($"{UiSymbols.Note} {fileName}: {ex.Message}"); - } - } - - // Provide summary feedback - if (installedCount > 0) - { - taskContext.AddDebugMessage($"{UiSymbols.Check} Installed {installedCount} MSIX packages"); - } - if (errorCount > 0) - { - taskContext.AddDebugMessage($"{UiSymbols.Note} {errorCount} packages failed to install"); - } - - return (installedCount, errorCount); - } - - // Gets the current system architecture string for package selection - public static string GetSystemArchitecture() - { - var arch = RuntimeInformation.ProcessArchitecture; - return arch switch - { - Architecture.X64 => "x64", - Architecture.Arm64 => "arm64", - Architecture.X86 => "x86", - _ => "x64" // Default fallback - }; - } - - // Finds the MSIX directory for Windows App SDK runtime packages - public DirectoryInfo? FindWindowsAppSdkMsixDirectory(Dictionary? usedVersions = null) - { - var nugetCacheDir = nugetService.GetNuGetGlobalPackagesDir(); - return FindMsixDirectoryInNuGetCache(nugetCacheDir, usedVersions); - } - - // Searches the NuGet global packages cache (lowercase id/version folder convention). - private DirectoryInfo? FindMsixDirectoryInNuGetCache(DirectoryInfo nugetCacheDir, Dictionary? usedVersions) - { - if (usedVersions != null) - { - // Try runtime package first (Windows App SDK 1.8+) - if (usedVersions.TryGetValue(BuildToolsService.WINAPP_SDK_RUNTIME_PACKAGE, out var runtimeVersion)) - { - var msixDir = TryGetMsixDirectoryFromNuGetCache(BuildToolsService.WINAPP_SDK_RUNTIME_PACKAGE, runtimeVersion); - if (msixDir != null) - { - return msixDir; - } - } - - // Fallback to main package - if (usedVersions.TryGetValue(BuildToolsService.WINAPP_SDK_PACKAGE, out var mainVersion)) - { - var msixDir = TryGetMsixDirectoryFromNuGetCache(BuildToolsService.WINAPP_SDK_PACKAGE, mainVersion); - if (msixDir != null) - { - return msixDir; - } - } - } - - // General scan: look for any runtime package directories - var runtimeDir = new DirectoryInfo(Path.Combine(nugetCacheDir.FullName, BuildToolsService.WINAPP_SDK_RUNTIME_PACKAGE.ToLowerInvariant())); - if (runtimeDir.Exists) - { - foreach (var versionDir in runtimeDir.GetDirectories().OrderByDescending(d => d.Name, new VersionStringComparer())) - { - var msixDir = TryGetMsixDirectoryFromPath(versionDir); - if (msixDir != null) - { - return msixDir; - } - } - } - - // Fallback: main package - var mainDir = new DirectoryInfo(Path.Combine(nugetCacheDir.FullName, BuildToolsService.WINAPP_SDK_PACKAGE.ToLowerInvariant())); - if (mainDir.Exists) - { - foreach (var versionDir in mainDir.GetDirectories().OrderByDescending(d => d.Name, new VersionStringComparer())) - { - var msixDir = TryGetMsixDirectoryFromPath(versionDir); - if (msixDir != null) - { - return msixDir; - } - } - } - - return null; - } - - // Checks the NuGet cache for a specific package/version. - private DirectoryInfo? TryGetMsixDirectoryFromNuGetCache(string packageId, string version) - { - var pkgVersionDir = nugetService.GetNuGetPackageDir(packageId, version); - return TryGetMsixDirectoryFromPath(pkgVersionDir); - } - - // Helper method to check if an MSIX directory exists for a given package path - private static DirectoryInfo? TryGetMsixDirectoryFromPath(DirectoryInfo packagePath) - { - var msixDir = new DirectoryInfo(Path.Combine(packagePath.FullName, "tools", "MSIX")); - return msixDir.Exists ? msixDir : null; - } - - // Comparer for sorting version strings, including prerelease support - private class VersionStringComparer : IComparer - { - public int Compare(string? x, string? y) - { - if (x == null && y == null) - { - return 0; - } - if (x == null) - { - return -1; - } - if (y == null) - { - return 1; - } - - // Use the same comparison logic as NugetService.CompareVersions - return NugetService.CompareVersions(x, y); - } - } -} diff --git a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Options.cs b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Options.cs deleted file mode 100644 index a2ad89b1..00000000 --- a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Options.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using WinApp.Cli.Models; - -namespace WinApp.Cli.Services; - -// Parameters for workspace setup operations -internal class WorkspaceSetupOptions -{ - public required DirectoryInfo BaseDirectory { get; set; } - public required DirectoryInfo ConfigDir { get; set; } - public SdkInstallMode? SdkInstallMode { get; set; } - public bool IgnoreConfig { get; set; } - public bool NoGitignore { get; set; } - public bool UseDefaults { get; set; } - public bool RequireExistingConfig { get; set; } - public bool ForceLatestBuildTools { get; set; } - public bool ConfigOnly { get; set; } -} diff --git a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Prompts.cs b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Prompts.cs deleted file mode 100644 index d72499c5..00000000 --- a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.Prompts.cs +++ /dev/null @@ -1,240 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using Microsoft.Extensions.Logging; -using Spectre.Console; -using WinApp.Cli.Helpers; -using WinApp.Cli.Models; - -namespace WinApp.Cli.Services; - -// WorkspaceSetupService — Spectre.Console prompt slice. -// -// Holds every interactive prompt the init/restore flow needs (manifest -// generation, developer-mode toggle, SDK install mode, .csproj picker, -// confirmation). Splitting these out keeps the orchestration file focused -// on control flow and lets us evolve UI without churning the main file. -internal partial class WorkspaceSetupService -{ - // Selects the .csproj file to configure when multiple are found. - private async Task SelectCsprojFileAsync(IReadOnlyList csprojFiles, CancellationToken cancellationToken) - { - if (csprojFiles.Count == 1) - { - return csprojFiles[0]; - } - - // Multiple .csproj files found — ask the user which one to use - var choices = csprojFiles.Select(f => f.Name).ToArray(); - var selected = await ansiConsole.PromptAsync( - new SelectionPrompt() - .Title("Multiple .csproj files found. Which project should be configured?") - .AddChoices(choices), - cancellationToken); - return csprojFiles.First(f => f.Name == selected); - } - - private static async Task ShowConfirmationPromptAsync(IAnsiConsole ansiConsole, string prompt, CancellationToken cancellationToken) - { - var result = await ansiConsole.PromptAsync(new ConfirmationPrompt(prompt), cancellationToken); - - ansiConsole.Cursor.MoveUp(); - ansiConsole.Write("\x1b[2K"); // Clear line - ansiConsole.MarkupLine($"{prompt}: [underline]{(result ? "Yes" : "No")}[/]"); - - return result; - } - - private async Task PromptForManifestInfoAsync(WorkspaceSetupOptions options, CancellationToken cancellationToken) - { - if (options.ConfigOnly) - { - return null; - } - - return await manifestService.PromptForManifestInfoAsync(options.BaseDirectory, null, null, "1.0.0.0", "Windows Application", null, options.UseDefaults, cancellationToken); - } - - private async Task AskShouldEnableDeveloperModeAsync(WorkspaceSetupOptions options, CancellationToken cancellationToken) - { - if (options.ConfigOnly || options.RequireExistingConfig) - { - return false; - } - - if (devModeService.IsEnabled()) - { - return false; - } - - if (options.UseDefaults) - { - return false; - } - - return await ShowConfirmationPromptAsync(ansiConsole, "Enable Developer Mode (requires elevation and you will be prompted by User Account Control)", cancellationToken); - } - - private async Task AskShouldGenerateManifestAsync(WorkspaceSetupOptions options, CancellationToken cancellationToken) - { - if (options.RequireExistingConfig) - { - return true; - } - - // Check if manifest already exists, and if so, ask about overwriting - var manifestPath = MsixService.FindProjectManifest(currentDirectoryProvider, options.BaseDirectory); - if ((manifestPath?.Exists) == true) - { - logger.LogDebug("{UISymbol} {ManifestFileName} already exists at {ManifestPath}", UiSymbols.Check, manifestPath.Name, manifestPath.FullName); - if (options.UseDefaults) - { - // With --use-defaults, skip overwriting existing manifest (non-destructive) - return false; - } - else - { - return await ShowConfirmationPromptAsync(ansiConsole, $"{manifestPath.Name} already exists. Overwrite?", cancellationToken); - } - } - - return true; - } - - private async Task AskSdkInstallModeAsync(WorkspaceSetupOptions options, bool isDotNetProject, FileInfo? csprojFile, CancellationToken cancellationToken) - { - // For init (not restore), prompt for SDK installation choice if not specified - if (!options.RequireExistingConfig && !options.ConfigOnly && options.SdkInstallMode == null) - { - // If the .NET project already references WinAppSDK, skip the prompt and default to None. - // This call may take a while on a fresh machine because `dotnet list package` triggers - // an implicit restore — surface a spinner so the user knows we're doing something (#463). - if (isDotNetProject && csprojFile != null) - { - var alreadyReferencesWinAppSdk = await RunWithStatusAsync( - "Detecting project SDK references...", - ct => dotNetService.HasPackageReferenceAsync(csprojFile, DotNetService.WINAPP_SDK_NUGET_PACKAGE, ct), - cancellationToken); - if (alreadyReferencesWinAppSdk) - { - options.SdkInstallMode = SdkInstallMode.None; - logger.LogInformation("{UISymbol} Project already references {PackageName}; skipping Windows App SDK setup.", UiSymbols.Check, DotNetService.WINAPP_SDK_NUGET_PACKAGE); - return; - } - } - // Determine which packages to show versions for - var packages = isDotNetProject - ? [BuildToolsService.WINAPP_SDK_PACKAGE] - : new[] { BuildToolsService.CPP_SDK_PACKAGE, BuildToolsService.WINAPP_SDK_PACKAGE }; - - // Fetch versions for all modes in parallel (failures are non-fatal). On a fresh machine - // these NuGet feed calls can take many seconds; show a spinner so the prompt doesn't - // appear to hang (#463). - var modes = new[] { SdkInstallMode.Stable, SdkInstallMode.Preview, SdkInstallMode.Experimental }; - var versionTasks = await RunWithStatusAsync( - "Fetching latest SDK versions...", - async ct => - { - var tasks = modes - .SelectMany(mode => packages.Select(pkg => (Mode: mode, Package: pkg, Task: SafeGetLatestVersionAsync(pkg, mode, ct)))) - .ToList(); - await Task.WhenAll(tasks.Select(v => v.Task)); - return tasks; - }, - cancellationToken); - - // Build a lookup: (mode) → version label - var versionsByMode = modes.ToDictionary( - mode => mode, - mode => - { - var parts = versionTasks - .Where(v => v.Mode == mode && v.Task.Result != null) - .Select(v => $"{(v.Package == BuildToolsService.CPP_SDK_PACKAGE ? "Windows SDK" : "Windows App SDK")} [green]{v.Task.Result}[/]"); - return string.Join(", ", parts); - }); - - var label = isDotNetProject ? "Windows App SDK" : "SDKs"; - string FormatChoice(string modeLabel, SdkInstallMode mode) - { - var versions = versionsByMode[mode]; - return string.IsNullOrEmpty(versions) - ? $"Setup {modeLabel} {label}" - : $"Setup {modeLabel} {label} ({versions})"; - } - string[] sdkChoices = [ - FormatChoice("Stable", SdkInstallMode.Stable), - FormatChoice("Preview", SdkInstallMode.Preview), - FormatChoice("Experimental", SdkInstallMode.Experimental), - $"Do not setup {label}" - ]; - - ansiConsole.WriteLine($"Select {label} setup option:"); - var sdkPrompt = new SelectionPrompt() - .AddChoices(sdkChoices); - - var sdkChoice = await ansiConsole.PromptAsync(sdkPrompt, cancellationToken); - - ansiConsole.Cursor.MoveUp(); - ansiConsole.Write("\x1b[2K"); // Clear line - - if (sdkChoice == sdkChoices[0]) - { - options.SdkInstallMode = SdkInstallMode.Stable; - } - else if (sdkChoice == sdkChoices[1]) - { - options.SdkInstallMode = SdkInstallMode.Preview; - } - else if (sdkChoice == sdkChoices[2]) - { - options.SdkInstallMode = SdkInstallMode.Experimental; - } - else - { - options.SdkInstallMode = SdkInstallMode.None; - logger.LogInformation("Setup {Label}: Do not setup {Label}", label, label); - return; - } - - ansiConsole.MarkupLine($"Setup {label}: [underline]{Markup.Remove(sdkChoice["Setup ".Length..])}[/]"); - } - } - - private async Task SafeGetLatestVersionAsync(string packageName, SdkInstallMode mode, CancellationToken cancellationToken) - { - try - { - return await nugetService.GetLatestVersionAsync(packageName, sdkInstallMode: mode, cancellationToken: cancellationToken); - } - catch (Exception ex) - { - logger.LogDebug("Failed to fetch latest version for {PackageName} ({Mode}): {ErrorMessage}", packageName, mode, ex.Message); - return null; - } - } - - // Runs work while showing a Spectre.Console spinner with message. - // In non-interactive contexts (redirected output, no Information logging), - // falls back to a single log line so the user still sees what's happening (#463). - private async Task RunWithStatusAsync(string message, Func> work, CancellationToken cancellationToken) - { - if (Environment.UserInteractive - && !Console.IsOutputRedirected - && logger.IsEnabled(LogLevel.Information) - && ansiConsole.Profile.Capabilities.Interactive) - { - T result = default!; - await ansiConsole.Status() - .Spinner(Spinner.Known.Dots) - .StartAsync(message, async _ => - { - result = await work(cancellationToken); - }); - return result; - } - - logger.LogInformation("{Message}", message); - return await work(cancellationToken); - } -} diff --git a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.cs b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.cs index c7f1b079..d67d65cf 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.cs @@ -10,16 +10,26 @@ namespace WinApp.Cli.Services; -// Shared service for setting up winapp workspaces. Split into partials: -// - this file: orchestration (SetupWorkspaceAsync, init/restore flow) -// - WorkspaceSetupService.Options.cs: option DTO (WorkspaceSetupOptions) -// - WorkspaceSetupService.Prompts.cs: Spectre.Console prompts (SDK choice, manifest, dev mode, .csproj picker) -// - WorkspaceSetupService.Msix.cs: Windows App SDK runtime MSIX install / NuGet-cache discovery -// -// JS/TS bindings orchestration (codegen, runtime-dep injection, prompt) lives -// entirely in the @microsoft/winapp npm wrapper, which calls this native CLI -// for `init`/`restore` and reads the lockfile written by Step 5. -internal partial class WorkspaceSetupService( +/// +/// Parameters for workspace setup operations +/// +internal class WorkspaceSetupOptions +{ + public required DirectoryInfo BaseDirectory { get; set; } + public required DirectoryInfo ConfigDir { get; set; } + public SdkInstallMode? SdkInstallMode { get; set; } + public bool IgnoreConfig { get; set; } + public bool NoGitignore { get; set; } + public bool UseDefaults { get; set; } + public bool RequireExistingConfig { get; set; } + public bool ForceLatestBuildTools { get; set; } + public bool ConfigOnly { get; set; } +} + +/// +/// Shared service for setting up winapp workspaces +/// +internal class WorkspaceSetupService( IConfigService configService, IWinappDirectoryService winappDirectoryService, IPackageInstallationService packageInstallationService, @@ -66,7 +76,6 @@ public async Task SetupWorkspaceAsync(WorkspaceSetupOptions options, Cancel } // Restore on a non-.NET project with no winapp.yaml — nothing to restore. - // (.NET projects without yaml are already rejected on line 61 above.) // No-op success rather than error: a project that doesn't declare SDK // package versions in winapp.yaml simply has nothing for restore to do. if (options.RequireExistingConfig && !configService.Exists()) @@ -507,7 +516,7 @@ await taskContext.AddSubTaskAsync("Configuring developer mode", async (taskConte return (1, "Error installing packages."); } - // Step 5: Run cppwinrt and set up projections. + // Step 5: Run cppwinrt and set up projections var cppWinrtExe = cppWinrtService.FindCppWinrtExe(nugetCacheDir, usedVersions); if (cppWinrtExe is null) { @@ -553,18 +562,20 @@ await taskContext.AddSubTaskAsync("Configuring developer mode", async (taskConte taskContext.AddDebugMessage($"{UiSymbols.Note} Failed to copy license: {ex.Message}"); } - // Collect winmd inputs (the npm-wrapper JS bindings pipeline reads the lockfile too). + // Collect winmd inputs and run cppwinrt taskContext.UpdateSubStatus("Searching for .winmd metadata"); var winmds = packageLayoutService.FindWinmds(nugetCacheDir, usedVersions).ToList(); taskContext.AddDebugMessage($"{UiSymbols.Search} Found {winmds.Count} .winmd"); if (winmds.Count == 0) { - return (2, "No .winmd files found in installed SDK packages."); + return (2, "No .winmd files found for C++/WinRT projection."); } - // Persist the lockfile so the JS bindings step can skip re-globbing - // / re-fetching nuspecs. Hash source must match what lands in - // winapp.yaml (SDK_PACKAGES-filtered for fresh init, config.Packages for restore). + // Write the winmds lockfile alongside the cppwinrt outputs so the + // npm wrapper's jsBindings orchestrator can replay the same winmd + // inventory without re-walking the NuGet cache. Hash captures the + // winapp.yaml `packages:` block (SDK_PACKAGES-filtered for fresh init, + // config.Packages for restore) so the wrapper can detect yaml drift. var yamlHash = (options.RequireExistingConfig && config?.Packages.Count > 0) ? YamlPackagesHasher.Compute(config.Packages) : YamlPackagesHasher.ComputeFromVersions(usedVersions @@ -786,6 +797,26 @@ await taskContext.AddSubTaskAsync("Updating Directory.Packages.props", (taskCont }, cancellationToken); } + /// + /// Selects the .csproj file to configure when multiple are found. + /// + private async Task SelectCsprojFileAsync(IReadOnlyList csprojFiles, CancellationToken cancellationToken) + { + if (csprojFiles.Count == 1) + { + return csprojFiles[0]; + } + + // Multiple .csproj files found — ask the user which one to use + var choices = csprojFiles.Select(f => f.Name).ToArray(); + var selected = await ansiConsole.PromptAsync( + new SelectionPrompt() + .Title("Multiple .csproj files found. Which project should be configured?") + .AddChoices(choices), + cancellationToken); + return csprojFiles.First(f => f.Name == selected); + } + private async Task SetupManifestSubTaskAsync(WorkspaceSetupOptions options, bool shouldGenerateManifest, ManifestGenerationInfo? manifestGenerationInfo, TaskContext taskContext, CancellationToken cancellationToken) { await taskContext.AddSubTaskAsync("Generating Manifest and Assets", async (taskContext, cancellationToken) => @@ -816,4 +847,675 @@ await manifestService.GenerateManifestAsync( } }, cancellationToken); } + + private async Task<(int ReturnCode, WinappConfig? Config, bool HadExistingConfig, bool ShouldGenerateManifest, ManifestGenerationInfo? ManifestGenerationInfo, bool ShouldEnableDeveloperMode, string? RecommendedTfm)> InitializeConfigurationAsync(WorkspaceSetupOptions options, bool isDotNetProject, FileInfo? csprojFile, CancellationToken cancellationToken) + { + if (!options.RequireExistingConfig && !options.ConfigOnly && options.SdkInstallMode == null && options.UseDefaults) + { + // Default to Stable when --use-defaults + options.SdkInstallMode = SdkInstallMode.Stable; + } + + var hadExistingConfig = configService.Exists(); + bool shouldGenerateManifest = true; + bool shouldEnableDeveloperMode = false; + string? recommendedTfm = null; + ManifestGenerationInfo? manifestGenerationInfo = null; + WinappConfig? config = null; + + // Step 1: Handle configuration requirements + if (options.RequireExistingConfig && !configService.Exists()) + { + // Non-.NET project with no winapp.yaml — nothing to restore. + // (.NET projects without yaml are handled earlier in SetupWorkspaceAsync.) + // This is a no-op rather than an error: a project that doesn't declare + // SDK package versions in winapp.yaml has nothing for restore to do. + logger.LogInformation("{UISymbol} No winapp.yaml found in {ConfigDir}. Nothing to restore.", UiSymbols.Note, options.ConfigDir); + logger.LogInformation("If this project needs Windows SDK packages, run 'winapp init' to set them up."); + return (0, config, hadExistingConfig, shouldGenerateManifest, manifestGenerationInfo, shouldEnableDeveloperMode, recommendedTfm); + } + + // Step 2: Load or prepare configuration + if (hadExistingConfig) + { + config = configService.Load(); + + if (config.Packages.Count == 0 && options.RequireExistingConfig) + { + logger.LogInformation("{UISymbol} winapp.yaml found but contains no packages. Nothing to restore.", UiSymbols.Note); + shouldEnableDeveloperMode = await AskShouldEnableDeveloperModeAsync(options, cancellationToken); + return (0, config, hadExistingConfig, shouldGenerateManifest, manifestGenerationInfo, shouldEnableDeveloperMode, recommendedTfm); + } + + var operation = options.RequireExistingConfig ? "Found" : "Found existing"; + logger.LogDebug("{UISymbol} {Operation} winapp.yaml with {PackageCount} packages", UiSymbols.Package, operation, config.Packages.Count); + + if (!options.RequireExistingConfig && config.Packages.Count > 0) + { + logger.LogDebug("{UISymbol} Using pinned package versions from winapp.yaml unless overridden.", UiSymbols.Note); + } + + // For setup command: ask about overwriting existing config (only if not skipping SDK installation and not config-only mode) + if (!options.RequireExistingConfig && !options.IgnoreConfig && !options.ConfigOnly && options.SdkInstallMode != SdkInstallMode.None && config.Packages.Count > 0) + { + if (options.UseDefaults) + { + options.IgnoreConfig = true; + } + else + { + var overwriteConfig = await ShowConfirmationPromptAsync(ansiConsole, "winapp.yaml exists with pinned versions. Overwrite?", cancellationToken); + shouldGenerateManifest = await AskShouldGenerateManifestAsync(options, cancellationToken); + if (shouldGenerateManifest) + { + manifestGenerationInfo = await PromptForManifestInfoAsync(options, cancellationToken); + } + if (!overwriteConfig) + { + options.IgnoreConfig = true; + } + else + { + await AskSdkInstallModeAsync(options, isDotNetProject, csprojFile, cancellationToken); + } + } + } + } + else + { + shouldGenerateManifest = await AskShouldGenerateManifestAsync(options, cancellationToken); + if (shouldGenerateManifest) + { + manifestGenerationInfo = await PromptForManifestInfoAsync(options, cancellationToken); + } + + await AskSdkInstallModeAsync(options, isDotNetProject, csprojFile, cancellationToken); + if (options.SdkInstallMode != SdkInstallMode.None) + { + config = new WinappConfig(); + logger.LogDebug("{UISymbol} No winapp.yaml found; will generate one after setup.", UiSymbols.New); + } + } + + // .NET: Validate TargetFramework (interactive) + if (isDotNetProject && csprojFile != null) + { + if (dotNetService.IsMultiTargeted(csprojFile)) + { + logger.LogError("The project '{CsprojFile}' uses multi-targeting (TargetFrameworks). winapp init does not support multi-targeted projects.", csprojFile.Name); + return (1, config, hadExistingConfig, shouldGenerateManifest, manifestGenerationInfo, shouldEnableDeveloperMode, recommendedTfm); + } + + var currentTfm = dotNetService.GetTargetFramework(csprojFile); + logger.LogDebug("Current TargetFramework: {Tfm}", currentTfm ?? "(not set)"); + + if (currentTfm == null || !dotNetService.IsTargetFrameworkSupported(currentTfm)) + { + recommendedTfm = dotNetService.GetRecommendedTargetFramework(currentTfm); + + if (!options.UseDefaults) + { + var currentDisplay = currentTfm ?? "(not set)"; + + var promptSuffix = options.SdkInstallMode != SdkInstallMode.None + ? " (Required for Windows App SDK)" + : ""; + + var shouldUpdate = await ShowConfirmationPromptAsync(ansiConsole, $"Update TargetFramework to \"{recommendedTfm}\"{promptSuffix}?", cancellationToken); + + if (!shouldUpdate) + { + if (options.SdkInstallMode != SdkInstallMode.None) + { + logger.LogError("TargetFramework '{Tfm}' is not supported for Windows App SDK. Cannot continue.", currentDisplay); + return (1, config, hadExistingConfig, shouldGenerateManifest, manifestGenerationInfo, shouldEnableDeveloperMode, recommendedTfm); + } + + // Not installing SDKs, so TFM update is not required — skip it + recommendedTfm = null; + } + } + else + { + var currentDisplay = currentTfm ?? "(not set)"; + logger.LogWarning( + "TargetFramework '{CurrentTfm}' is not supported for Windows App SDK. Automatically updating to '{RecommendedTfm}' because --use-defaults was specified.", + currentDisplay, + recommendedTfm); + logger.LogInformation("Automatically updating TargetFramework from {CurrentTfm} to {RecommendedTfm} because --use-defaults was specified.", Markup.Escape(currentDisplay), recommendedTfm); + } + } + else + { + logger.LogDebug("{UISymbol} TargetFramework '{Tfm}' is supported", UiSymbols.Check, currentTfm); + } + } + + shouldEnableDeveloperMode = await AskShouldEnableDeveloperModeAsync(options, cancellationToken); + + return (0, config, hadExistingConfig, shouldGenerateManifest, manifestGenerationInfo, shouldEnableDeveloperMode, recommendedTfm); + } + + private static async Task ShowConfirmationPromptAsync(IAnsiConsole ansiConsole, string prompt, CancellationToken cancellationToken) + { + var result = await ansiConsole.PromptAsync(new ConfirmationPrompt(prompt), cancellationToken); + + ansiConsole.Cursor.MoveUp(); + ansiConsole.Write("\x1b[2K"); // Clear line + ansiConsole.MarkupLine($"{prompt}: [underline]{(result ? "Yes" : "No")}[/]"); + + return result; + } + + private async Task PromptForManifestInfoAsync(WorkspaceSetupOptions options, CancellationToken cancellationToken) + { + if (options.ConfigOnly) + { + return null; + } + + return await manifestService.PromptForManifestInfoAsync(options.BaseDirectory, null, null, "1.0.0.0", "Windows Application", null, options.UseDefaults, cancellationToken); + } + + private async Task AskShouldEnableDeveloperModeAsync(WorkspaceSetupOptions options, CancellationToken cancellationToken) + { + if (options.ConfigOnly || options.RequireExistingConfig) + { + return false; + } + + if (devModeService.IsEnabled()) + { + return false; + } + + if (options.UseDefaults) + { + return false; + } + + return await ShowConfirmationPromptAsync(ansiConsole, "Enable Developer Mode (requires elevation and you will be prompted by User Account Control)", cancellationToken); + } + + private async Task AskShouldGenerateManifestAsync(WorkspaceSetupOptions options, CancellationToken cancellationToken) + { + if (options.RequireExistingConfig) + { + return true; + } + + // Check if manifest already exists, and if so, ask about overwriting + var manifestPath = MsixService.FindProjectManifest(currentDirectoryProvider, options.BaseDirectory); + if ((manifestPath?.Exists) == true) + { + logger.LogDebug("{UISymbol} {ManifestFileName} already exists at {ManifestPath}", UiSymbols.Check, manifestPath.Name, manifestPath.FullName); + if (options.UseDefaults) + { + // With --use-defaults, skip overwriting existing manifest (non-destructive) + return false; + } + else + { + return await ShowConfirmationPromptAsync(ansiConsole, $"{manifestPath.Name} already exists. Overwrite?", cancellationToken); + } + } + + return true; + } + + private async Task AskSdkInstallModeAsync(WorkspaceSetupOptions options, bool isDotNetProject, FileInfo? csprojFile, CancellationToken cancellationToken) + { + // For init (not restore), prompt for SDK installation choice if not specified + if (!options.RequireExistingConfig && !options.ConfigOnly && options.SdkInstallMode == null) + { + // If the .NET project already references WinAppSDK, skip the prompt and default to None. + // This call may take a while on a fresh machine because `dotnet list package` triggers + // an implicit restore — surface a spinner so the user knows we're doing something (#463). + if (isDotNetProject && csprojFile != null) + { + var alreadyReferencesWinAppSdk = await RunWithStatusAsync( + "Detecting project SDK references...", + ct => dotNetService.HasPackageReferenceAsync(csprojFile, DotNetService.WINAPP_SDK_NUGET_PACKAGE, ct), + cancellationToken); + if (alreadyReferencesWinAppSdk) + { + options.SdkInstallMode = SdkInstallMode.None; + logger.LogInformation("{UISymbol} Project already references {PackageName}; skipping Windows App SDK setup.", UiSymbols.Check, DotNetService.WINAPP_SDK_NUGET_PACKAGE); + return; + } + } + // Determine which packages to show versions for + var packages = isDotNetProject + ? [BuildToolsService.WINAPP_SDK_PACKAGE] + : new[] { BuildToolsService.CPP_SDK_PACKAGE, BuildToolsService.WINAPP_SDK_PACKAGE }; + + // Fetch versions for all modes in parallel (failures are non-fatal). On a fresh machine + // these NuGet feed calls can take many seconds; show a spinner so the prompt doesn't + // appear to hang (#463). + var modes = new[] { SdkInstallMode.Stable, SdkInstallMode.Preview, SdkInstallMode.Experimental }; + var versionTasks = await RunWithStatusAsync( + "Fetching latest SDK versions...", + async ct => + { + var tasks = modes + .SelectMany(mode => packages.Select(pkg => (Mode: mode, Package: pkg, Task: SafeGetLatestVersionAsync(pkg, mode, ct)))) + .ToList(); + await Task.WhenAll(tasks.Select(v => v.Task)); + return tasks; + }, + cancellationToken); + + // Build a lookup: (mode) → version label + var versionsByMode = modes.ToDictionary( + mode => mode, + mode => + { + var parts = versionTasks + .Where(v => v.Mode == mode && v.Task.Result != null) + .Select(v => $"{(v.Package == BuildToolsService.CPP_SDK_PACKAGE ? "Windows SDK" : "Windows App SDK")} [green]{v.Task.Result}[/]"); + return string.Join(", ", parts); + }); + + var label = isDotNetProject ? "Windows App SDK" : "SDKs"; + string FormatChoice(string modeLabel, SdkInstallMode mode) + { + var versions = versionsByMode[mode]; + return string.IsNullOrEmpty(versions) + ? $"Setup {modeLabel} {label}" + : $"Setup {modeLabel} {label} ({versions})"; + } + string[] sdkChoices = [ + FormatChoice("Stable", SdkInstallMode.Stable), + FormatChoice("Preview", SdkInstallMode.Preview), + FormatChoice("Experimental", SdkInstallMode.Experimental), + $"Do not setup {label}" + ]; + + ansiConsole.WriteLine($"Select {label} setup option:"); + var sdkPrompt = new SelectionPrompt() + .AddChoices(sdkChoices); + + var sdkChoice = await ansiConsole.PromptAsync(sdkPrompt, cancellationToken); + + ansiConsole.Cursor.MoveUp(); + ansiConsole.Write("\x1b[2K"); // Clear line + + if (sdkChoice == sdkChoices[0]) + { + options.SdkInstallMode = SdkInstallMode.Stable; + } + else if (sdkChoice == sdkChoices[1]) + { + options.SdkInstallMode = SdkInstallMode.Preview; + } + else if (sdkChoice == sdkChoices[2]) + { + options.SdkInstallMode = SdkInstallMode.Experimental; + } + else + { + options.SdkInstallMode = SdkInstallMode.None; + logger.LogInformation("Setup {Label}: Do not setup {Label}", label, label); + return; + } + + ansiConsole.MarkupLine($"Setup {label}: [underline]{Markup.Remove(sdkChoice["Setup ".Length..])}[/]"); + } + } + + private async Task SafeGetLatestVersionAsync(string packageName, SdkInstallMode mode, CancellationToken cancellationToken) + { + try + { + return await nugetService.GetLatestVersionAsync(packageName, sdkInstallMode: mode, cancellationToken: cancellationToken); + } + catch (Exception ex) + { + logger.LogDebug("Failed to fetch latest version for {PackageName} ({Mode}): {ErrorMessage}", packageName, mode, ex.Message); + return null; + } + } + + /// + /// Package entry information from MSIX inventory + /// + public class MsixPackageEntry + { + public required string FileName { get; set; } + public required string PackageIdentity { get; set; } + } + + /// + /// Parses the MSIX inventory file and returns package entries (shared implementation) + /// + /// Directory containing the MSIX packages + /// Cancellation token + /// List of package entries, or null if not found + public static async Task?> ParseMsixInventoryAsync(TaskContext taskContext, DirectoryInfo msixDir, CancellationToken cancellationToken) + { + var architecture = GetSystemArchitecture(); + + taskContext.AddDebugMessage($"{UiSymbols.Note} Detected system architecture: {architecture}"); + + // Look for MSIX packages for the current architecture + var msixArchDir = Path.Combine(msixDir.FullName, $"win10-{architecture}"); + if (!Directory.Exists(msixArchDir)) + { + taskContext.AddDebugMessage($"{UiSymbols.Note} No MSIX packages found for architecture {architecture}"); + taskContext.AddDebugMessage($"{UiSymbols.Note} Available directories: {string.Join(", ", msixDir.GetDirectories().Select(d => d.Name))}"); + return null; + } + + // Read the MSIX inventory file + var inventoryPath = Path.Combine(msixArchDir, "msix.inventory"); + if (!File.Exists(inventoryPath)) + { + taskContext.AddDebugMessage($"{UiSymbols.Note} No msix.inventory file found in {msixArchDir}"); + return null; + } + + var inventoryLines = await File.ReadAllLinesAsync(inventoryPath, cancellationToken); + var packageEntries = inventoryLines + .Where(line => !string.IsNullOrWhiteSpace(line) && line.Contains('=')) + .Select(line => line.Split('=', 2)) + .Where(parts => parts.Length == 2) + .Select(parts => new MsixPackageEntry { FileName = parts[0].Trim(), PackageIdentity = parts[1].Trim() }) + .ToList(); + + if (packageEntries.Count == 0) + { + taskContext.AddDebugMessage($"{UiSymbols.Note} No valid package entries found in msix.inventory"); + return null; + } + + taskContext.AddDebugMessage($"{UiSymbols.Package} Found {packageEntries.Count} MSIX packages in inventory"); + + return packageEntries; + } + + /// + /// Reads the actual package Name and Version from the AppxManifest.xml inside an MSIX file. + /// The MSIX inventory file can have incorrect package names (e.g., the DDLM), so we read + /// the real identity directly from the package to ensure correct installation checks. + /// + private static (string? Name, string? Version) ReadMsixIdentity(string msixFilePath, TaskContext taskContext) + { + try + { + using var zip = System.IO.Compression.ZipFile.OpenRead(msixFilePath); + var manifestEntry = zip.GetEntry("AppxManifest.xml"); + if (manifestEntry == null) + { + return (null, null); + } + + using var stream = manifestEntry.Open(); + var doc = System.Xml.Linq.XDocument.Load(stream); + var identityElement = doc.Root?.Elements() + .FirstOrDefault(e => e.Name.LocalName == "Identity"); + + var name = identityElement?.Attribute("Name")?.Value; + var version = identityElement?.Attribute("Version")?.Value; + return (name, version); + } + catch (Exception ex) + { + taskContext.AddDebugMessage($"{UiSymbols.Note} Could not read identity from {Path.GetFileName(msixFilePath)}: {ex.Message}"); + return (null, null); + } + } + + /// + /// Installs Windows App SDK runtime MSIX packages for the current system architecture + /// + /// Directory containing the MSIX packages + /// Cancellation token + public async Task<(int InstalledCount, int ErrorCount)> InstallWindowsAppRuntimeAsync(DirectoryInfo msixDir, TaskContext taskContext, CancellationToken cancellationToken) + { + var architecture = GetSystemArchitecture(); + + // Get package entries from MSIX inventory + var packageEntries = await ParseMsixInventoryAsync(taskContext, msixDir, cancellationToken); + if (packageEntries == null || packageEntries.Count == 0) + { + return (0, 0); + } + + var msixArchDir = Path.Combine(msixDir.FullName, $"win10-{architecture}"); + + // Build list of packages to evaluate + var packagesToCheck = new List<(string FilePath, string PackageName, string NewVersion, string FileName)>(); + foreach (var entry in packageEntries) + { + var msixFilePath = Path.Combine(msixArchDir, entry.FileName); + if (!File.Exists(msixFilePath)) + { + taskContext.AddDebugMessage($"{UiSymbols.Note} MSIX file not found: {msixFilePath}"); + continue; + } + + // Read the actual package identity from the MSIX's AppxManifest.xml. + // The inventory file's PackageIdentity can differ from the real installed name. + var (packageName, newVersionString) = ReadMsixIdentity(msixFilePath, taskContext); + if (packageName == null) + { + // Fallback: parse from inventory identity string + var identityParts = entry.PackageIdentity.Split('_'); + packageName = identityParts[0]; + newVersionString = identityParts.Length >= 2 ? identityParts[1] : ""; + } + + packagesToCheck.Add((msixFilePath, packageName, newVersionString ?? "", entry.FileName)); + } + + if (packagesToCheck.Count == 0) + { + return (0, 0); + } + + taskContext.AddDebugMessage($"{UiSymbols.Info} Checking and installing {packagesToCheck.Count} MSIX packages"); + + var installedCount = 0; + var errorCount = 0; + + foreach (var (filePath, packageName, newVersion, fileName) in packagesToCheck) + { + // Check if already installed with same or newer version + var installedVersion = packageRegistrationService.GetInstalledVersion(packageName); + if (installedVersion != null) + { + if (Version.TryParse(installedVersion, out var existing) && + Version.TryParse(newVersion, out var incoming) && + existing >= incoming) + { + taskContext.AddDebugMessage($"{UiSymbols.Check} {fileName}: Already installed or newer version exists"); + continue; + } + } + + taskContext.AddDebugMessage($"{UiSymbols.Info} {fileName}: Will install"); + + try + { + await packageRegistrationService.InstallPackageAsync(filePath, cancellationToken); + installedCount++; + taskContext.AddDebugMessage($"{UiSymbols.Check} {fileName}: Installation successful"); + } + catch (Exception ex) + { + errorCount++; + taskContext.AddDebugMessage($"{UiSymbols.Note} {fileName}: {ex.Message}"); + } + } + + // Provide summary feedback + if (installedCount > 0) + { + taskContext.AddDebugMessage($"{UiSymbols.Check} Installed {installedCount} MSIX packages"); + } + if (errorCount > 0) + { + taskContext.AddDebugMessage($"{UiSymbols.Note} {errorCount} packages failed to install"); + } + + return (installedCount, errorCount); + } + + /// + /// Gets the current system architecture string for package selection + /// + /// Architecture string (x64, arm64, x86) + public static string GetSystemArchitecture() + { + var arch = RuntimeInformation.ProcessArchitecture; + return arch switch + { + Architecture.X64 => "x64", + Architecture.Arm64 => "arm64", + Architecture.X86 => "x86", + _ => "x64" // Default fallback + }; + } + + /// + /// Finds the MSIX directory for Windows App SDK runtime packages + /// + /// Optional dictionary of package versions to look for specific installed packages + /// The path to the MSIX directory, or null if not found + public DirectoryInfo? FindWindowsAppSdkMsixDirectory(Dictionary? usedVersions = null) + { + var nugetCacheDir = nugetService.GetNuGetGlobalPackagesDir(); + return FindMsixDirectoryInNuGetCache(nugetCacheDir, usedVersions); + } + + /// + /// Searches the NuGet global packages cache (lowercase id/version folder convention). + /// + private static DirectoryInfo? FindMsixDirectoryInNuGetCache(DirectoryInfo nugetCacheDir, Dictionary? usedVersions) + { + if (usedVersions != null) + { + // Try runtime package first (Windows App SDK 1.8+) + if (usedVersions.TryGetValue(BuildToolsService.WINAPP_SDK_RUNTIME_PACKAGE, out var runtimeVersion)) + { + var msixDir = TryGetMsixDirectoryFromNuGetCache(nugetCacheDir, BuildToolsService.WINAPP_SDK_RUNTIME_PACKAGE, runtimeVersion); + if (msixDir != null) + { + return msixDir; + } + } + + // Fallback to main package + if (usedVersions.TryGetValue(BuildToolsService.WINAPP_SDK_PACKAGE, out var mainVersion)) + { + var msixDir = TryGetMsixDirectoryFromNuGetCache(nugetCacheDir, BuildToolsService.WINAPP_SDK_PACKAGE, mainVersion); + if (msixDir != null) + { + return msixDir; + } + } + } + + // General scan: look for any runtime package directories + var runtimeDir = new DirectoryInfo(Path.Combine(nugetCacheDir.FullName, BuildToolsService.WINAPP_SDK_RUNTIME_PACKAGE.ToLowerInvariant())); + if (runtimeDir.Exists) + { + foreach (var versionDir in runtimeDir.GetDirectories().OrderByDescending(d => d.Name, new VersionStringComparer())) + { + var msixDir = TryGetMsixDirectoryFromPath(versionDir); + if (msixDir != null) + { + return msixDir; + } + } + } + + // Fallback: main package + var mainDir = new DirectoryInfo(Path.Combine(nugetCacheDir.FullName, BuildToolsService.WINAPP_SDK_PACKAGE.ToLowerInvariant())); + if (mainDir.Exists) + { + foreach (var versionDir in mainDir.GetDirectories().OrderByDescending(d => d.Name, new VersionStringComparer())) + { + var msixDir = TryGetMsixDirectoryFromPath(versionDir); + if (msixDir != null) + { + return msixDir; + } + } + } + + return null; + } + + /// + /// Checks the NuGet cache for a specific package/version (lowercase ID/version layout). + /// + private static DirectoryInfo? TryGetMsixDirectoryFromNuGetCache(DirectoryInfo nugetCacheDir, string packageId, string version) + { + // NuGet global cache uses lowercase package IDs + var pkgVersionDir = new DirectoryInfo(Path.Combine(nugetCacheDir.FullName, packageId.ToLowerInvariant(), version)); + return TryGetMsixDirectoryFromPath(pkgVersionDir); + } + + /// + /// Helper method to check if an MSIX directory exists for a given package path + /// + /// The full path to the package directory + /// The MSIX directory path if it exists, null otherwise + private static DirectoryInfo? TryGetMsixDirectoryFromPath(DirectoryInfo packagePath) + { + var msixDir = new DirectoryInfo(Path.Combine(packagePath.FullName, "tools", "MSIX")); + return msixDir.Exists ? msixDir : null; + } + + /// + /// Runs while showing a Spectre.Console spinner with . + /// In non-interactive contexts (redirected output, no Information logging), falls back to a single + /// log line so the user still sees what's happening (#463). + /// + private async Task RunWithStatusAsync(string message, Func> work, CancellationToken cancellationToken) + { + if (Environment.UserInteractive + && !Console.IsOutputRedirected + && logger.IsEnabled(LogLevel.Information) + && ansiConsole.Profile.Capabilities.Interactive) + { + T result = default!; + await ansiConsole.Status() + .Spinner(Spinner.Known.Dots) + .StartAsync(message, async _ => + { + result = await work(cancellationToken); + }); + return result; + } + + logger.LogInformation("{Message}", message); + return await work(cancellationToken); + } + + /// + /// Comparer for sorting version strings, including prerelease support + /// + private class VersionStringComparer : IComparer + { + public int Compare(string? x, string? y) + { + if (x == null && y == null) + { + return 0; + } + if (x == null) + { + return -1; + } + if (y == null) + { + return 1; + } + + // Use the same comparison logic as NugetService.CompareVersions + return NugetService.CompareVersions(x, y); + } + } } From 8d995ba2b307073a95da9ccd67db80eeefc1b9c9 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Fri, 22 May 2026 22:41:31 +0800 Subject: [PATCH 18/27] update doc and code clean --- .../skills/winapp-cli/ui-automation.md | 2 +- docs/guides/electron/js-bindings.md | 79 ++- scripts/generate-llm-docs.ps1 | 42 +- .../WinApp.Cli.Tests/MsixServiceTests.cs | 33 +- .../WinApp.Cli.Tests/PathSafetyTests.cs | 16 +- .../UpdateNotificationGatingTests.cs | 23 +- .../WinappConfigDocumentTests.cs | 614 ------------------ .../WinApp.Cli/Helpers/ManifestHelper.cs | 8 +- .../WinApp.Cli/Helpers/PathSafety.cs | 49 +- .../WinApp.Cli/Services/ConfigService.cs | 80 ++- .../WinApp.Cli/Services/MsixService.cs | 23 +- .../Services/WinappConfigDocument.cs | 337 ---------- src/winapp-npm/package-lock.json | 8 +- src/winapp-npm/package.json | 2 +- src/winapp-npm/scripts/generate-commands.mjs | 22 +- 15 files changed, 175 insertions(+), 1163 deletions(-) delete mode 100644 src/winapp-CLI/WinApp.Cli.Tests/WinappConfigDocumentTests.cs delete mode 100644 src/winapp-CLI/WinApp.Cli/Services/WinappConfigDocument.cs diff --git a/docs/fragments/skills/winapp-cli/ui-automation.md b/docs/fragments/skills/winapp-cli/ui-automation.md index 950a8c98..d5648199 100644 --- a/docs/fragments/skills/winapp-cli/ui-automation.md +++ b/docs/fragments/skills/winapp-cli/ui-automation.md @@ -178,7 +178,7 @@ The `--json` envelope for `ui inspect`, `ui get-focused`, `ui search`, and `ui w - `ui search --json` / `ui wait-for --json` may include an `invokableAncestor` field (element-shaped) on each match. - Per-element `id`, `parentSelector`, and `windowHandle` are **removed** — use `selector` as the public handle. -Full schemas with examples: [`references/ui-json-envelope.md`](./references/ui-json-envelope.md). +Full schemas with examples: `references/ui-json-envelope.md`. ## Related skills - `winapp-setup` for adding Windows SDK to your project diff --git a/docs/guides/electron/js-bindings.md b/docs/guides/electron/js-bindings.md index 8bcaeae6..51b2d18e 100644 --- a/docs/guides/electron/js-bindings.md +++ b/docs/guides/electron/js-bindings.md @@ -1,7 +1,9 @@ # Calling WinRT APIs from JavaScript (JS / TypeScript bindings) -This guide shows you how to call modern Windows Runtime (WinRT) APIs directly from your Electron app's JavaScript or TypeScript — **without** writing a C++ or C# native addon. `winapp` integrates the [`@microsoft/dynwinrt-codegen`](https://www.npmjs.com/package/@microsoft/dynwinrt-codegen) codegen, which produces typed JS + `.d.ts` bindings for WinAppSDK (and any other WinRT) APIs from their `.winmd` metadata. The generated bindings then use [`@microsoft/dynwinrt`](https://www.npmjs.com/package/@microsoft/dynwinrt) to access the underlying WinRT APIs directly at runtime. The result: full IntelliSense at compile time, no `node-gyp` / MSBuild step from your Electron project. +Call modern Windows Runtime (WinRT) APIs directly from JavaScript or TypeScript — **no native addon, no `node-gyp` / MSBuild step, full IntelliSense**. + +`winapp` does this by running [`@microsoft/dynwinrt-codegen`](https://www.npmjs.com/package/@microsoft/dynwinrt-codegen) against your `.winmd` metadata to emit typed `.js` + `.d.ts` bindings; at runtime they delegate to [`@microsoft/dynwinrt`](https://www.npmjs.com/package/@microsoft/dynwinrt). > **When to choose JS bindings over a native addon:** when the API ships in a `.winmd` (most of `Windows.*` and `Microsoft.WindowsAppSDK.*`). Reach for a native addon only when there's no WinRT projection — Win32 / pure COM (raw `IFileDialog`, registry, custom COM servers), C++ libraries that ship only headers + a static/shared lib, or vendor SDKs that ship only a managed .NET assembly. See the C++ / C# addon guides for those cases. @@ -71,13 +73,13 @@ To put them somewhere else, set `output` inside `winapp.jsBindings` in `package. Import from the generated `index.js` — you don't need to know which file inside `bindings/` a class lives in. Here's a native file picker (`Microsoft.Windows.Storage.Pickers.FileOpenPicker`) opened from your Electron main process. This API works on any Windows 11 machine once you've wired up debug identity in [Step 3](#step-3-run-it): ```js -// src/index.js (Electron main) -const { app, BrowserWindow } = require('electron'); -const { +// src/main.js (Electron main) +import { app, BrowserWindow, ipcMain } from 'electron'; +import { FileOpenPicker, PickerLocationId, PickerViewMode, -} = require('../bindings/index.js'); +} from '../bindings/index.js'; async function pickAnImage(mainWindow) { // FileOpenPicker needs the parent window's HWND wrapped in a WindowId struct. @@ -93,11 +95,70 @@ async function pickAnImage(mainWindow) { return result?.path; // string with the chosen path, or undefined if the user cancelled } -// Usage (after `app.whenReady()`): -// const path = await pickAnImage(BrowserWindow.getFocusedWindow()); -// if (path) console.log('Picked:', path); +// Expose it to the renderer via IPC so a button click can trigger the picker. +ipcMain.handle('pick-image', (event) => { + const win = BrowserWindow.fromWebContents(event.sender); + return pickAnImage(win); +}); +``` + +Then bridge it into the renderer through your preload script: + +```js +// src/preload.js +import { contextBridge, ipcRenderer } from 'electron'; + +contextBridge.exposeInMainWorld('winapp', { + pickImage: () => ipcRenderer.invoke('pick-image'), +}); ``` +…and call it from the renderer (e.g. on a button click): + +```html + + +``` + +```js +// src/renderer.js +document.querySelector('#pick-image-btn').addEventListener('click', async () => { + const path = await window.winapp.pickImage(); + console.log(path ? `Picked: ${path}` : 'Picker cancelled.'); +}); +``` + +See [Electron's IPC docs](https://www.electronjs.org/docs/latest/tutorial/ipc) for the broader pattern (preload + `contextBridge` is the recommended way to expose any main-process API — WinRT or otherwise — to your UI). + +> [!IMPORTANT] +> **Using Vite (the current `electron-forge` default)?** Externalize `@microsoft/dynwinrt` in `vite.main.config.mjs`, otherwise the build fails with `Unexpected character '\0'`: +> +> ```js +> import { defineConfig } from 'vite'; +> +> export default defineConfig({ +> build: { +> rollupOptions: { +> external: ['@microsoft/dynwinrt'], +> }, +> }, +> }); +> ``` +> +> The `electron-forge` Webpack template (`--template=webpack` / `--template=webpack-typescript`) works out of the box — no config changes needed. + +> [!NOTE] +> **Which import syntax should I use?** Check your `package.json`: +> +> - **Has a bundler plugin** (`@electron-forge/plugin-vite`, `@electron-forge/plugin-webpack`, etc. — this is the **current `electron-forge` default**): use the top-level `import` shown above as-is. Done. +> - **No bundler** (older vanilla `electron-forge` template, or a hand-rolled Node project): pick one: +> - **Switch to ESM** — add `"type": "module"` to your `package.json`, then rename `forge.config.js` → `forge.config.cjs` and (if you have it) `scripts/postinstall.js` → `.cjs`. Then the `import` above works. +> - **Stay CommonJS** — replace the static `import` with a dynamic one inside an `async` function: +> ```js +> const { FileOpenPicker, PickerLocationId, PickerViewMode } = await import('../bindings/index.js'); +> ``` +> (Top-level `await` doesn't work in CommonJS, so the call must be inside `async`.) + The same `bindings/index.js` re-exports every other emitted class — `AppNotificationManager`, `PowerManager`, `WidgetManager`, and so on. Import what you need; the codegen has already generated typed declarations for everything in your `winapp.jsBindings` scope. A few conventions to remember: @@ -110,7 +171,7 @@ A few conventions to remember: Events follow an `on(handler)` shape that returns an unsubscribe function (`const off = obj.onSomething(cb); /* … */ off()`), and `IDisposable` WinRT objects should be wrapped in `try/finally` with a `.close()` call. -You can call the same API from the renderer via `contextBridge` / `ipcRenderer` — exactly as you would for a native addon. The bindings have no dependency on Electron's main process; they work anywhere Node.js can `require()` them. +You can call the same API from the renderer via `contextBridge` / `ipcRenderer` — exactly as you would for a native addon. The bindings have no dependency on Electron's main process; they work anywhere Node.js can load ES modules. ## Step 3: Run it diff --git a/scripts/generate-llm-docs.ps1 b/scripts/generate-llm-docs.ps1 index 6957a3a1..eb87c8f3 100644 --- a/scripts/generate-llm-docs.ps1 +++ b/scripts/generate-llm-docs.ps1 @@ -69,24 +69,10 @@ if ($LASTEXITCODE -ne 0) { exit 1 } -# Join lines into a single string with LF endings + one trailing newline. +# Join array lines into single string with LF line endings (CLI outputs pretty-printed JSON) +# Ensure exactly one trailing newline for consistency $SchemaJson = ($SchemaJsonLines -join "`n").TrimEnd() + "`n" -# Override schema version with version.json (CLI binary may have the -# AssemblyInformationalVersion default "1.0.0"). validate-llm-docs.ps1 -# does the same substitution at compare time. -$VersionJsonPath = Join-Path (Split-Path $PSScriptRoot) "version.json" -if (Test-Path $VersionJsonPath) { - $BaseVersion = (Get-Content $VersionJsonPath | ConvertFrom-Json).version - $SchemaObj = $SchemaJson | ConvertFrom-Json - if ($SchemaObj.version -ne $BaseVersion) { - Write-Host "[DOCS] Overriding schema version '$($SchemaObj.version)' (from CLI binary) with '$BaseVersion' (from version.json)" -ForegroundColor Yellow - $SchemaObj.version = $BaseVersion - $SchemaJson = ($SchemaObj | ConvertTo-Json -Depth 100) -replace "`r`n", "`n" - if (-not $SchemaJson.EndsWith("`n")) { $SchemaJson += "`n" } - } -} - # Save schema JSON with consistent LF line endings [System.IO.File]::WriteAllText($SchemaOutputPath, $SchemaJson, [System.Text.UTF8Encoding]::new($false)) Write-Host "[DOCS] Saved: $SchemaOutputPath" -ForegroundColor Green @@ -121,25 +107,15 @@ $SkillCommandMap = @{ # Validate that all CLI commands are covered by at least one skill $allMappedCommands = $SkillCommandMap.Values | ForEach-Object { $_ } | Where-Object { $_ } - -# Recursively enumerate all leaf command paths in the schema. -function Get-AllLeafPaths { - param([PSObject]$Node, [string]$Prefix) - - $paths = @() - if (-not $Node.subcommands) { - return @($Prefix) - } - foreach ($sub in $Node.subcommands.PSObject.Properties) { - $childPath = if ($Prefix) { "$Prefix $($sub.Name)" } else { $sub.Name } - $paths += Get-AllLeafPaths -Node $sub.Value -Prefix $childPath - } - return $paths -} - $allSchemaCommands = @() foreach ($cmd in $Schema.subcommands.PSObject.Properties) { - $allSchemaCommands += Get-AllLeafPaths -Node $cmd.Value -Prefix $cmd.Name + if ($cmd.Value.subcommands) { + foreach ($sub in $cmd.Value.subcommands.PSObject.Properties) { + $allSchemaCommands += "$($cmd.Name) $($sub.Name)" + } + } else { + $allSchemaCommands += $cmd.Name + } } $unmappedCommands = $allSchemaCommands | Where-Object { $_ -notin $allMappedCommands } if ($unmappedCommands) { diff --git a/src/winapp-CLI/WinApp.Cli.Tests/MsixServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/MsixServiceTests.cs index 705c52a4..3aec441b 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/MsixServiceTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/MsixServiceTests.cs @@ -687,15 +687,9 @@ public void FindManifestInDirectory_FindsAppxManifestXml() // Act var result = MsixService.FindManifestInDirectory(_tempDir); - // Assert: FindManifestInDirectory delegates to ManifestHelper which - // probes Package.appxmanifest → AppxManifest.xml → appxmanifest.xml. - // On NTFS (case-insensitive) the AppxManifest.xml probe matches the - // on-disk "appxmanifest.xml" file, so the returned Name reflects - // whichever spelling our precedence list probed first — compare - // case-insensitively. The contract is "a .xml manifest was found". + // Assert Assert.IsNotNull(result); - Assert.IsTrue(string.Equals("appxmanifest.xml", result.Name, StringComparison.OrdinalIgnoreCase), - $"Expected an appxmanifest.xml-family name, got '{result.Name}'"); + Assert.AreEqual("appxmanifest.xml", result.Name); } [TestMethod] @@ -727,29 +721,6 @@ public void FindManifestInDirectory_PrefersPackageAppxManifest_WhenBothExist() Assert.AreEqual("Package.appxmanifest", result.Name); } - [TestMethod] - public void FindManifestInDirectory_FindsAppxManifestXml_PascalCase() - { - // The NuGet targets' GetWinAppRunSupportInfo target probes - // AppxManifest.xml (Pascal-case) as one of the standard names — - // and ManifestHelper.GetWellKnownManifestFileNames lists it too. - // FindManifestInDirectory must accept the same spelling so - // `winapp run` / `create-debug-identity` find the same file the - // build outputs. - // - // On NTFS (case-insensitive) the actual returned Name may reflect - // whichever spelling our internal precedence list probed first, - // so we compare case-insensitively — the contract here is "the - // file was found", not "Name preserves the exact on-disk case". - File.WriteAllText(Path.Combine(_tempDir.FullName, "AppxManifest.xml"), ""); - - var result = MsixService.FindManifestInDirectory(_tempDir); - - Assert.IsNotNull(result); - Assert.IsTrue(string.Equals("AppxManifest.xml", result.Name, StringComparison.OrdinalIgnoreCase), - $"Expected an appxmanifest.xml-family name, got '{result.Name}'"); - } - [TestMethod] public void FindManifestInDirectory_ReturnsNull_WhenNoManifest() { diff --git a/src/winapp-CLI/WinApp.Cli.Tests/PathSafetyTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/PathSafetyTests.cs index 0f4b0555..cde91b1d 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/PathSafetyTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/PathSafetyTests.cs @@ -276,16 +276,16 @@ public void HasReparsePointOnPath_DriveRootBoundary_StillRejectsJunctionDescenda } // --------------------------------------------------------------------- - // AtomicWriteAllText (round-4 M3) + // AtomicWriteAllTextAsync (round-4 M3) // --------------------------------------------------------------------- [TestMethod] - public void AtomicWriteAllText_NewFile_WritesContentsAndLeavesNoTempBehind() + public async Task AtomicWriteAllTextAsync_NewFile_WritesContentsAndLeavesNoTempBehind() { var target = Path.Combine(_tempDir.FullName, "out.yaml"); const string contents = "key: value\n"; - PathSafety.AtomicWriteAllText(target, contents, System.Text.Encoding.UTF8); + await PathSafety.AtomicWriteAllTextAsync(target, contents, System.Text.Encoding.UTF8); Assert.IsTrue(File.Exists(target), "target file must exist after atomic write"); Assert.AreEqual(contents, File.ReadAllText(target)); @@ -295,18 +295,18 @@ public void AtomicWriteAllText_NewFile_WritesContentsAndLeavesNoTempBehind() } [TestMethod] - public void AtomicWriteAllText_ExistingFile_OverwritesContents() + public async Task AtomicWriteAllTextAsync_ExistingFile_OverwritesContents() { var target = Path.Combine(_tempDir.FullName, "existing.yaml"); File.WriteAllText(target, "old contents"); - PathSafety.AtomicWriteAllText(target, "new contents", System.Text.Encoding.UTF8); + await PathSafety.AtomicWriteAllTextAsync(target, "new contents", System.Text.Encoding.UTF8); Assert.AreEqual("new contents", File.ReadAllText(target)); } [TestMethod] - public void AtomicWriteAllText_DestinationDirMissing_ThrowsAndCleansSiblingTemp() + public async Task AtomicWriteAllTextAsync_DestinationDirMissing_ThrowsAndCleansSiblingTemp() { // Stage failure: the sibling temp creation calls FileStream with // FileMode.CreateNew under a non-existent parent dir, throwing @@ -316,8 +316,8 @@ public void AtomicWriteAllText_DestinationDirMissing_ThrowsAndCleansSiblingTemp( var missingDir = Path.Combine(_tempDir.FullName, "no-such-dir"); var target = Path.Combine(missingDir, "out.yaml"); - Assert.ThrowsExactly(() => - PathSafety.AtomicWriteAllText(target, "x", System.Text.Encoding.UTF8)); + await Assert.ThrowsExactlyAsync(async () => + await PathSafety.AtomicWriteAllTextAsync(target, "x", System.Text.Encoding.UTF8)); Assert.IsFalse(Directory.Exists(missingDir), "atomic write must not create parent dirs"); // No sibling temp under the workspace either (the failure happened diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationGatingTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationGatingTests.cs index aaa67195..5f3e0da5 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationGatingTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UpdateNotificationGatingTests.cs @@ -58,21 +58,14 @@ public void Cleanup() try { Directory.Delete(_tempCacheDir, recursive: true); } catch { /* best effort */ } } - // Substring uniquely produced by UpdateNotificationService.DisplayUpdateNotification - // (format: "v{ver} is available. To update, …"). Looser checks like - // .Contains("available") false-positive against command descriptions - // that use the word "available" (e.g. "Only available when invoked - // via the npm package"). - private const string UpdateNoticeMarker = "is available. To update"; - [TestMethod] public async Task JsonMode_SuppressesUpdateNotice_StdoutHasNoNotice() { var (stdout, stderr, _) = await InvokeProgramAsync(["get-winapp-path", "--global", "--json"]); - Assert.IsFalse(stdout.Contains(UpdateNoticeMarker, StringComparison.OrdinalIgnoreCase), + Assert.IsFalse(stdout.Contains("available", StringComparison.OrdinalIgnoreCase), $"--json stdout must not contain update notice. Got stdout: {stdout}"); - Assert.IsFalse(stderr.Contains(UpdateNoticeMarker, StringComparison.OrdinalIgnoreCase), + Assert.IsFalse(stderr.Contains("available", StringComparison.OrdinalIgnoreCase), $"--json stderr must not contain update notice. Got stderr: {stderr}"); } @@ -81,9 +74,9 @@ public async Task QuietMode_SuppressesUpdateNotice() { var (stdout, stderr, _) = await InvokeProgramAsync(["get-winapp-path", "--global", "--quiet"]); - Assert.IsFalse(stdout.Contains(UpdateNoticeMarker, StringComparison.OrdinalIgnoreCase), + Assert.IsFalse(stdout.Contains("available", StringComparison.OrdinalIgnoreCase), $"--quiet stdout must not contain update notice. Got stdout: {stdout}"); - Assert.IsFalse(stderr.Contains(UpdateNoticeMarker, StringComparison.OrdinalIgnoreCase), + Assert.IsFalse(stderr.Contains("available", StringComparison.OrdinalIgnoreCase), $"--quiet stderr must not contain update notice. Got stderr: {stderr}"); } @@ -92,9 +85,9 @@ public async Task CliSchemaMode_SuppressesUpdateNotice() { var (stdout, stderr, _) = await InvokeProgramAsync(["--cli-schema"]); - Assert.IsFalse(stdout.Contains(UpdateNoticeMarker, StringComparison.OrdinalIgnoreCase), + Assert.IsFalse(stdout.Contains("available", StringComparison.OrdinalIgnoreCase), $"--cli-schema stdout must not contain update notice. Got stdout: {stdout}"); - Assert.IsFalse(stderr.Contains(UpdateNoticeMarker, StringComparison.OrdinalIgnoreCase), + Assert.IsFalse(stderr.Contains("available", StringComparison.OrdinalIgnoreCase), $"--cli-schema stderr must not contain update notice. Got stderr: {stderr}"); } @@ -105,9 +98,9 @@ public async Task NormalMode_ShowsUpdateNotice_OnStderr() // never stdout. We capture stderr via Console.SetError. var (stdout, stderr, _) = await InvokeProgramAsync(["get-winapp-path", "--global"]); - Assert.IsFalse(stdout.Contains(UpdateNoticeMarker, StringComparison.OrdinalIgnoreCase), + Assert.IsFalse(stdout.Contains("available", StringComparison.OrdinalIgnoreCase), $"Update notice must not appear on stdout. Got stdout: {stdout}"); - Assert.IsTrue(stderr.Contains(UpdateNoticeMarker, StringComparison.OrdinalIgnoreCase), + Assert.IsTrue(stderr.Contains("available", StringComparison.OrdinalIgnoreCase), $"Update notice should appear on stderr in normal mode. Got stderr: {stderr}"); } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/WinappConfigDocumentTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/WinappConfigDocumentTests.cs deleted file mode 100644 index ebaf8762..00000000 --- a/src/winapp-CLI/WinApp.Cli.Tests/WinappConfigDocumentTests.cs +++ /dev/null @@ -1,614 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using WinApp.Cli.Models; -using WinApp.Cli.Services; - -// CA1861 ("avoid constant arrays as arguments") is real perf advice in hot -// paths, but these tests use one-shot literal arrays as fixture data and -// extracting each to a `static readonly` field would make the round-trip -// cases noticeably harder to read. Suppress at file scope. -#pragma warning disable CA1861 - -namespace WinApp.Cli.Tests; - -// Direct round-trip tests for the WinappConfigDocument YAML grammar. -// -// The native CLI owns a tiny hand-rolled YAML subset (just `packages:`) -// because pulling in YamlDotNet would balloon the NativeAOT trim surface. -// That makes parser/renderer drift the most likely failure mode, so this -// suite pins: -// * SanitizeScalar (inline `#` strip, quoted-scalar peel, apostrophe -// escape, plain-vs-quoted comment handling) -// * QuoteScalar (drive-letter paths, leading dash, reserved YAML -// booleans, numeric / boolean-looking versions) -// * Parse / Render round-trips that survive unknown top-level keys and -// inline comments on the `packages:` header -[TestClass] -public class WinappConfigDocumentTests -{ - private static WinappConfig RoundTrip(WinappConfig cfg) - { - var yaml = new WinappConfigDocument(cfg).Render(); - return WinappConfigDocument.Parse(yaml).Config; - } - - // --------------------------------------------------------------------- - // SanitizeScalar — inline `#` comment stripping - // --------------------------------------------------------------------- - - [TestMethod] - public void Parse_PackageVersionWithInlineComment_StripsComment() - { - // A plain (unquoted) `# comment` must be dropped from the version - // scalar — otherwise the comment text gets baked into the stored - // value and lockfile + restore reproduce the wrong pin on the next - // run. - var yaml = string.Join('\n', new[] - { - "packages:", - " - name: Microsoft.WindowsAppSDK", - " version: 1.8.39 # pinned for compat", - "", - }); - - var cfg = WinappConfigDocument.Parse(yaml).Config; - - Assert.AreEqual(1, cfg.Packages.Count); - Assert.AreEqual("Microsoft.WindowsAppSDK", cfg.Packages[0].Name); - Assert.AreEqual("1.8.39", cfg.Packages[0].Version); - } - - [TestMethod] - public void Parse_PackagesHeaderWithInlineComment_StillCollectsEntries() - { - // `packages: # SDK pins` must still open the packages section. - // Pre-fix the parser required an exact-string match and silently - // dropped every subsequent `- name:` / `version:` line. - var yaml = string.Join('\n', new[] - { - "packages: # SDK pins", - " - name: Microsoft.WindowsAppSDK", - " version: 1.8.39", - "", - }); - - var cfg = WinappConfigDocument.Parse(yaml).Config; - - Assert.AreEqual(1, cfg.Packages.Count, - "packages: with inline comment must still collect entries"); - Assert.AreEqual("Microsoft.WindowsAppSDK", cfg.Packages[0].Name); - Assert.AreEqual("1.8.39", cfg.Packages[0].Version); - } - - [TestMethod] - public void SanitizeScalar_QuotedHashInValue_PreservesHash() - { - // A `#` *inside* a quoted scalar is literal — it is NOT a comment - // boundary. The unit-level guard pins this so we don't reintroduce - // a "strip after first #" regression. - Assert.AreEqual("weird # name", - WinappConfigDocument.SanitizeScalar(" \"weird # name\"")); - Assert.AreEqual("note # foo", - WinappConfigDocument.SanitizeScalar(" 'note # foo'")); - } - - [TestMethod] - public void SanitizeScalar_PlainApostropheValue_StripsInlineComment() - { - // A plain (unquoted) `foo's-bar # comment` must drop the comment - // — the apostrophe is NOT a quote opener and must not suppress - // the `# comment` boundary detector. - Assert.AreEqual("foo's-bar", - WinappConfigDocument.SanitizeScalar(" foo's-bar # this is a comment")); - } - - // --------------------------------------------------------------------- - // SanitizeScalar — quoted-scalar peeling (incl. single-quote escape) - // --------------------------------------------------------------------- - - [TestMethod] - public void SanitizeScalar_SingleQuotedWithDoubledApostrophe_Unescapes() - { - // YAML single-quoted scalars use `''` as the literal-`'` escape. - // QuoteScalar emits `'O''Brien'`; SanitizeScalar must reverse it - // so round-trip is stable. - Assert.AreEqual("O'Brien", - WinappConfigDocument.SanitizeScalar(" 'O''Brien'")); - } - - [TestMethod] - public void SanitizeScalar_DoubleQuotedSimple_Peels() - { - Assert.AreEqual("hello", - WinappConfigDocument.SanitizeScalar(" \"hello\"")); - } - - [TestMethod] - public void SanitizeScalar_AsymmetricQuotes_DoesNotPeel() - { - // `it's` (plain) must NOT become `it`s` — the outer-quote peel only - // runs when both ends match the opener. - Assert.AreEqual("it's", - WinappConfigDocument.SanitizeScalar(" it's")); - } - - // --------------------------------------------------------------------- - // QuoteScalar — values the renderer MUST quote - // --------------------------------------------------------------------- - - [TestMethod] - public void RoundTrip_PackageWithDriveLetterColon_PreservedAsString() - { - // `C:\winmds\extra` contains `:` so the renderer must quote; - // otherwise the next load re-parses it as a mapping and drops - // the value. We cover this via the packages: list because that - // is the only field today; it exercises the same QuoteScalar / - // SanitizeScalar pipeline as any other scalar. - var cfg = new WinappConfig - { - Packages = - { - new PackagePin { Name = @"C:\vendor\Foo", Version = "1.0.0" }, - }, - }; - - var rt = RoundTrip(cfg); - - Assert.AreEqual(1, rt.Packages.Count); - Assert.AreEqual(@"C:\vendor\Foo", rt.Packages[0].Name); - Assert.AreEqual("1.0.0", rt.Packages[0].Version); - } - - [TestMethod] - public void RoundTrip_PackageNameContainingApostrophe_PreservesValue() - { - var cfg = new WinappConfig - { - Packages = - { - new PackagePin { Name = "Some.Vendor's.Package", Version = "1.0.0" }, - }, - }; - - var rt = RoundTrip(cfg); - - Assert.AreEqual("Some.Vendor's.Package", rt.Packages[0].Name); - } - - [TestMethod] - public void RoundTrip_PackageNameContainingHashChar_PreservesValue() - { - // An unquoted `#` introduces a comment; the renderer must quote - // and the parser must NOT strip the `#` from the quoted value. - var cfg = new WinappConfig - { - Packages = - { - new PackagePin { Name = "Vendor.C#-Package", Version = "1.0.0" }, - }, - }; - - var rt = RoundTrip(cfg); - - Assert.AreEqual("Vendor.C#-Package", rt.Packages[0].Name); - } - - [TestMethod] - public void RoundTrip_NumericLookingVersion_PreservedAsString() - { - // A version like `1.0` would otherwise re-parse as the double - // `1.0` and lose its string identity. NeedsQuoting must catch - // numeric-looking values and quote them. - var cfg = new WinappConfig - { - Packages = - { - new PackagePin { Name = "Vendor.Pkg", Version = "1.0" }, - new PackagePin { Name = "Vendor.IntPkg", Version = "42" }, - }, - }; - - var rt = RoundTrip(cfg); - - Assert.AreEqual("1.0", rt.Packages[0].Version); - Assert.AreEqual("42", rt.Packages[1].Version); - } - - [TestMethod] - public void RoundTrip_ReservedYamlBooleanLikeValue_PreservedAsString() - { - // A version like `no` (unusual but legal) would be re-parsed as - // the YAML 1.1 boolean false; the renderer must quote. - var cfg = new WinappConfig - { - Packages = - { - new PackagePin { Name = "no", Version = "yes" }, - }, - }; - - var rt = RoundTrip(cfg); - - Assert.AreEqual("no", rt.Packages[0].Name); - Assert.AreEqual("yes", rt.Packages[0].Version); - } - - [TestMethod] - public void RoundTrip_ValueLeadingWithDash_PreservedAsString() - { - // A leading `-` would otherwise be parsed as a YAML list marker. - var cfg = new WinappConfig - { - Packages = - { - new PackagePin { Name = "-leading-dash-pkg", Version = "1.0.0" }, - }, - }; - - var rt = RoundTrip(cfg); - - Assert.AreEqual("-leading-dash-pkg", rt.Packages[0].Name); - } - - // --------------------------------------------------------------------- - // Parse — unknown top-level keys must not leak into known sections - // --------------------------------------------------------------------- - - [TestMethod] - public void Parse_UnknownTopLevelKey_DoesNotAbsorbItsChildren() - { - // A future / unknown top-level key (e.g. `jsBindings:` which now - // lives in package.json) must NOT push its children into the - // packages: section. - var yaml = string.Join('\n', new[] - { - "jsBindings:", - " packages:", - " - Microsoft.WindowsAppSDK", - "packages:", - " - name: Microsoft.WindowsAppSDK", - " version: 1.8.39", - "", - }); - - var cfg = WinappConfigDocument.Parse(yaml).Config; - - Assert.AreEqual(1, cfg.Packages.Count, - "unknown top-level key must not pollute packages"); - Assert.AreEqual("Microsoft.WindowsAppSDK", cfg.Packages[0].Name); - Assert.AreEqual("1.8.39", cfg.Packages[0].Version); - } - - [TestMethod] - public void Parse_EmptyDocument_ProducesEmptyConfig() - { - var cfg = WinappConfigDocument.Parse(string.Empty).Config; - Assert.AreEqual(0, cfg.Packages.Count); - } - - [TestMethod] - public void Parse_OnlyCommentsAndBlankLines_ProducesEmptyConfig() - { - var yaml = string.Join('\n', new[] - { - "# this is a comment", - "", - " # indented comment", - "", - }); - - var cfg = WinappConfigDocument.Parse(yaml).Config; - Assert.AreEqual(0, cfg.Packages.Count); - } - - // --------------------------------------------------------------------- - // Render — output must round-trip identically through Parse - // --------------------------------------------------------------------- - - [TestMethod] - public void Render_MultiplePackages_ParsesBackToSameConfig() - { - var cfg = new WinappConfig - { - Packages = - { - new PackagePin { Name = "Microsoft.WindowsAppSDK", Version = "1.8.39" }, - new PackagePin { Name = "Microsoft.Web.WebView2", Version = "1.0.2592.51" }, - }, - }; - - var rt = RoundTrip(cfg); - - Assert.AreEqual(2, rt.Packages.Count); - Assert.AreEqual("Microsoft.WindowsAppSDK", rt.Packages[0].Name); - Assert.AreEqual("1.8.39", rt.Packages[0].Version); - Assert.AreEqual("Microsoft.Web.WebView2", rt.Packages[1].Name); - Assert.AreEqual("1.0.2592.51", rt.Packages[1].Version); - } - - [TestMethod] - public void Render_EmptyConfig_StillEmitsPackagesHeader() - { - // `packages:` (with no entries) is the canonical empty form. - // Render must always emit it so a subsequent Parse round-trips - // to the same config. - var doc = new WinappConfigDocument(new WinappConfig()); - var yaml = doc.Render(); - - StringAssert.Contains(yaml, "packages:"); - var rt = WinappConfigDocument.Parse(yaml).Config; - Assert.AreEqual(0, rt.Packages.Count); - } - - [TestMethod] - public void Render_OutputEndsWithNewline_StableUnderRepeatedRoundTrip() - { - // Rendering must be idempotent: Parse → Render → Parse → Render - // produces the same bytes the second time. Trailing-newline drift - // is a common source of "diff churn on every save". - var cfg = new WinappConfig - { - Packages = - { - new PackagePin { Name = "Microsoft.WindowsAppSDK", Version = "1.8.39" }, - }, - }; - - var first = new WinappConfigDocument(cfg).Render(); - var second = new WinappConfigDocument(WinappConfigDocument.Parse(first).Config).Render(); - - Assert.AreEqual(first, second, "Render must be idempotent under Parse → Render"); - } - - // --------------------------------------------------------------------- - // TryParseBool — small public helper, easy regression target - // --------------------------------------------------------------------- - - [TestMethod] - public void TryParseBool_AcceptsYamlBooleanLiterals() - { - Assert.IsTrue(WinappConfigDocument.TryParseBool("true", out var v) && v); - Assert.IsTrue(WinappConfigDocument.TryParseBool("YES", out v) && v); - Assert.IsTrue(WinappConfigDocument.TryParseBool(" on ", out v) && v); - Assert.IsTrue(WinappConfigDocument.TryParseBool("1", out v) && v); - - Assert.IsTrue(WinappConfigDocument.TryParseBool("false", out v) && !v); - Assert.IsTrue(WinappConfigDocument.TryParseBool("No", out v) && !v); - Assert.IsTrue(WinappConfigDocument.TryParseBool("off", out v) && !v); - Assert.IsTrue(WinappConfigDocument.TryParseBool("0", out v) && !v); - - Assert.IsFalse(WinappConfigDocument.TryParseBool("maybe", out _)); - Assert.IsFalse(WinappConfigDocument.TryParseBool(string.Empty, out _)); - } - - [TestMethod] - public void IsTopLevelKey_AcceptsExactAndCommentedHeader() - { - Assert.IsTrue(WinappConfigDocument.IsTopLevelKey("packages:", "packages:")); - Assert.IsTrue(WinappConfigDocument.IsTopLevelKey("packages: ", "packages:")); - Assert.IsTrue(WinappConfigDocument.IsTopLevelKey("packages: # sdk pins", "packages:")); - Assert.IsFalse(WinappConfigDocument.IsTopLevelKey("packageses:", "packages:")); - Assert.IsFalse(WinappConfigDocument.IsTopLevelKey("- packages:", "packages:")); - } - - // --------------------------------------------------------------------- - // Parse — additional coverage: duplicate keys, full-grammar round-trip - // --------------------------------------------------------------------- - - [TestMethod] - public void Parse_DuplicatePackagesKey_AppendsEntriesFromBothBlocks() - { - // A pathological (or hand-edited) `winapp.yaml` may contain the - // `packages:` key more than once. Pin the documented behavior — - // the parser re-enters the section and ACCUMULATES entries — so a - // refactor doesn't accidentally drop the second block silently. - var yaml = string.Join('\n', new[] - { - "packages:", - " - name: Microsoft.WindowsAppSDK", - " version: 1.8.39", - "packages:", - " - name: Microsoft.Web.WebView2", - " version: 1.0.2592.51", - "", - }); - - var cfg = WinappConfigDocument.Parse(yaml).Config; - - Assert.AreEqual(2, cfg.Packages.Count, - "duplicate top-level packages: keys must accumulate entries (not drop the second block)"); - Assert.AreEqual("Microsoft.WindowsAppSDK", cfg.Packages[0].Name); - Assert.AreEqual("Microsoft.Web.WebView2", cfg.Packages[1].Name); - } - - [TestMethod] - public void RoundTrip_FullGrammarSurface_StableUnderAllQuotingRules() - { - // One round-trip that exercises EVERY QuoteScalar branch at once - // (drive-letter colon, apostrophe, hash, numeric-looking version, - // boolean-like version, leading-dash name) plus a plain entry. - // A regression in any single rule will surface here as a - // mis-ordered or mangled pair — single-rule tests above pin the - // exact failure mode, this one pins the COMBINED render-parse - // contract. - var cfg = new WinappConfig - { - Packages = - { - new PackagePin { Name = "Plain.Vendor.Package", Version = "1.2.3" }, - new PackagePin { Name = @"C:\vendor\Local", Version = "0.1.0" }, - new PackagePin { Name = "Some.Vendor's.Package", Version = "2.0" }, - new PackagePin { Name = "Vendor.C#-Package", Version = "42" }, - new PackagePin { Name = "-leading-dash-pkg", Version = "yes" }, - new PackagePin { Name = "no", Version = "off" }, - }, - }; - - var rt = RoundTrip(cfg); - - Assert.AreEqual(6, rt.Packages.Count); - Assert.AreEqual("Plain.Vendor.Package", rt.Packages[0].Name); - Assert.AreEqual("1.2.3", rt.Packages[0].Version); - Assert.AreEqual(@"C:\vendor\Local", rt.Packages[1].Name); - Assert.AreEqual("0.1.0", rt.Packages[1].Version); - Assert.AreEqual("Some.Vendor's.Package", rt.Packages[2].Name); - Assert.AreEqual("2.0", rt.Packages[2].Version); - Assert.AreEqual("Vendor.C#-Package", rt.Packages[3].Name); - Assert.AreEqual("42", rt.Packages[3].Version); - Assert.AreEqual("-leading-dash-pkg", rt.Packages[4].Name); - Assert.AreEqual("yes", rt.Packages[4].Version); - Assert.AreEqual("no", rt.Packages[5].Name); - Assert.AreEqual("off", rt.Packages[5].Version); - - // Second-round serialization must equal the first (idempotency - // already covered for one entry; pin it for the full-grammar case). - var firstYaml = new WinappConfigDocument(cfg).Render(); - var secondYaml = new WinappConfigDocument(WinappConfigDocument.Parse(firstYaml).Config).Render(); - Assert.AreEqual(firstYaml, secondYaml, "Render must remain idempotent across the full quoting surface"); - } - - [TestMethod] - public void Parse_InputWithoutTrailingNewline_RoundTripsStablyAndAppendsNewline() - { - // A hand-edited winapp.yaml may not end with a newline. Verify: - // * Parse succeeds (no off-by-one on the missing terminator) - // * Render appends a single trailing newline - // * A subsequent Parse → Render is byte-identical (idempotent) - // This is the splice-into-existing-content edge case the Render - // idempotency test (which starts from a Render'd string) does not - // exercise — Render already terminates with \n, so re-parsing - // never sees the "missing trailing newline" surface. - var noTrailingNewline = "packages:\n - name: Microsoft.WindowsAppSDK\n version: 1.8.39"; - Assert.IsFalse(noTrailingNewline.EndsWith('\n'), - "fixture sanity check: input must not end with a newline"); - - var firstRender = new WinappConfigDocument(WinappConfigDocument.Parse(noTrailingNewline).Config).Render(); - var secondRender = new WinappConfigDocument(WinappConfigDocument.Parse(firstRender).Config).Render(); - - Assert.IsTrue(firstRender.EndsWith('\n'), - "Render must always emit a trailing newline regardless of the source document"); - Assert.AreEqual(firstRender, secondRender, - "Parse → Render must be byte-stable across a second round-trip"); - - var rt = WinappConfigDocument.Parse(firstRender).Config; - Assert.AreEqual(1, rt.Packages.Count); - Assert.AreEqual("Microsoft.WindowsAppSDK", rt.Packages[0].Name); - Assert.AreEqual("1.8.39", rt.Packages[0].Version); - } - - // --------------------------------------------------------------------- - // File-IO splice tests — exercise the exact byte sequence - // ConfigService.Save uses (Render → PathSafety.AtomicWriteAllText with - // UTF8-no-BOM), then read the on-disk bytes back and re-parse. The - // in-memory Render() tests above can't catch BOM injection, mid-write - // truncation, or encoding drift between the writer and reader. - // --------------------------------------------------------------------- - - private static readonly System.Text.UTF8Encoding SaveEncoding = - new(encoderShouldEmitUTF8Identifier: false); - - private static string WriteWithSavePath(string tempPath, WinappConfig cfg) - { - var yaml = new WinappConfigDocument(cfg).Render(); - WinApp.Cli.Helpers.PathSafety.AtomicWriteAllText(tempPath, yaml, SaveEncoding); - return tempPath; - } - - [TestMethod] - public void Splice_IntoEmptyFile_ProducesValidYaml() - { - // A user (or a stale process) may have left an empty winapp.yaml on - // disk. ConfigService.Save must overwrite it with a complete document - // that round-trips through Parse — the empty file must not poison - // anything (header missing, BOM injected, etc). - var tempPath = Path.Combine(Path.GetTempPath(), $"winapp-splice-empty-{Guid.NewGuid():N}.yaml"); - try - { - File.WriteAllBytes(tempPath, Array.Empty()); - Assert.AreEqual(0, new FileInfo(tempPath).Length, "fixture: file must start empty"); - - var cfg = new WinappConfig(); - cfg.Packages.Add(new PackagePin { Name = "Microsoft.WindowsAppSDK", Version = "1.8.39" }); - WriteWithSavePath(tempPath, cfg); - - var bytes = File.ReadAllBytes(tempPath); - Assert.IsTrue(bytes.Length > 0, "Save must overwrite the empty file with rendered content"); - // No UTF-8 BOM: ConfigService.Utf8NoBom mirror. - Assert.IsFalse(bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF, - "Save must not emit a UTF-8 BOM"); - - var saved = File.ReadAllText(tempPath, SaveEncoding); - var rt = WinappConfigDocument.Parse(saved).Config; - Assert.AreEqual(1, rt.Packages.Count); - Assert.AreEqual("Microsoft.WindowsAppSDK", rt.Packages[0].Name); - Assert.AreEqual("1.8.39", rt.Packages[0].Version); - } - finally - { - try { File.Delete(tempPath); } catch { /* best effort */ } - } - } - - [TestMethod] - public void Splice_PreservesTrailingNewline() - { - // Save → on-disk bytes must end with '\n'. A second Save against the - // same config must produce byte-identical bytes (file-level - // idempotency, not just in-memory Render idempotency). - var tempPath = Path.Combine(Path.GetTempPath(), $"winapp-splice-newline-{Guid.NewGuid():N}.yaml"); - try - { - var cfg = new WinappConfig(); - cfg.Packages.Add(new PackagePin { Name = "Microsoft.WindowsAppSDK", Version = "1.8.39" }); - cfg.Packages.Add(new PackagePin { Name = "Microsoft.UI.Xaml", Version = "2.8.6" }); - - var firstBytes = File.ReadAllBytes(WriteWithSavePath(tempPath, cfg)); - Assert.IsTrue(firstBytes.Length > 0, "Save must produce a non-empty file"); - Assert.AreEqual((byte)'\n', firstBytes[^1], "Save must terminate the file with a trailing newline"); - - var secondBytes = File.ReadAllBytes(WriteWithSavePath(tempPath, cfg)); - CollectionAssert.AreEqual(firstBytes, secondBytes, - "Re-saving the same config must produce byte-identical on-disk content"); - } - finally - { - try { File.Delete(tempPath); } catch { /* best effort */ } - } - } - - [TestMethod] - public void Splice_NoTrailingNewline_AddsOneAndStaysIdempotent() - { - // A hand-edited winapp.yaml saved without a trailing newline must - // get one back after the next Save, and a second Save must remain - // byte-stable. This is the disk-side analogue of the - // Parse_InputWithoutTrailingNewline_RoundTripsStablyAndAppendsNewline - // in-memory test — only the file path can detect a writer/encoder - // bug that injects extra bytes during persistence. - var tempPath = Path.Combine(Path.GetTempPath(), $"winapp-splice-nonl-{Guid.NewGuid():N}.yaml"); - try - { - var seed = "packages:\n - name: Legacy.Pkg\n version: 0.1.0"; - Assert.IsFalse(seed.EndsWith('\n'), "fixture: seed must not end with a newline"); - File.WriteAllText(tempPath, seed, SaveEncoding); - Assert.AreNotEqual((byte)'\n', File.ReadAllBytes(tempPath)[^1], - "fixture: on-disk seed must not end with a newline"); - - // Re-parse the on-disk bytes and save through the production - // sequence; the new file must end with '\n' and stay stable. - var loaded = WinappConfigDocument.Parse(File.ReadAllText(tempPath, SaveEncoding)).Config; - var firstBytes = File.ReadAllBytes(WriteWithSavePath(tempPath, loaded)); - Assert.AreEqual((byte)'\n', firstBytes[^1], - "Save must append a trailing newline when the source document lacked one"); - - var secondBytes = File.ReadAllBytes(WriteWithSavePath(tempPath, loaded)); - CollectionAssert.AreEqual(firstBytes, secondBytes, - "A second Save against the now-newline-terminated file must be byte-stable"); - } - finally - { - try { File.Delete(tempPath); } catch { /* best effort */ } - } - } -} diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/ManifestHelper.cs b/src/winapp-CLI/WinApp.Cli/Helpers/ManifestHelper.cs index ec0ddf69..0864b74f 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/ManifestHelper.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/ManifestHelper.cs @@ -8,15 +8,11 @@ namespace WinApp.Cli.Helpers; ///
internal static class ManifestHelper { - private static readonly string[] ManifestNames = ["Package.appxmanifest", "AppxManifest.xml", "appxmanifest.xml"]; + private static readonly string[] ManifestNames = ["Package.appxmanifest", "appxmanifest.xml"]; /// /// Finds an appxmanifest file in the specified directory. - /// Checks for Package.appxmanifest first, then AppxManifest.xml, - /// then appxmanifest.xml. The two .xml spellings are equivalent on - /// case-insensitive filesystems (NTFS) but differ on case-sensitive ones - /// (POSIX-style); both spellings are listed in our NuGet targets, so this - /// helper accepts both as well. + /// Checks for Package.appxmanifest first, then appxmanifest.xml. /// /// A for the manifest. Check before using. public static FileInfo FindManifest(string directory) diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/PathSafety.cs b/src/winapp-CLI/WinApp.Cli/Helpers/PathSafety.cs index 43c2c7e6..3af3f6fc 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/PathSafety.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/PathSafety.cs @@ -6,9 +6,9 @@ namespace WinApp.Cli.Helpers; // Shared filesystem-safety helpers. Centralizing the reparse-point / -// containment check keeps callers (ConfigService, WinmdsLockfileService) -// consistent — every "write into the user's workspace" site needs the -// same guard, and we don't want one to drift behind the others. +// containment check keeps every "write into the user's workspace" site +// (e.g. WinmdsLockfileService) consistent — we don't want one to drift +// behind the others. internal static class PathSafety { // True if `path` is not safely contained under `boundary`, or if any @@ -199,47 +199,8 @@ private static bool TryGetAttributes(string path, out FileAttributes attributes) // Write `contents` to `path` atomically: stage to a sibling temp file // (same volume so the move stays atomic), flush to disk, then rename // over the destination. Prevents a crash / power loss mid-write from - // leaving the file truncated or empty. - public static void AtomicWriteAllText(string path, string contents, System.Text.Encoding encoding) - { - var dir = Path.GetDirectoryName(path); - if (string.IsNullOrEmpty(dir)) - { - dir = Directory.GetCurrentDirectory(); - } - var tmp = Path.Combine(dir, Path.GetFileName(path) + ".tmp-" + Guid.NewGuid().ToString("N")); - try - { - using (var fs = new FileStream(tmp, FileMode.CreateNew, FileAccess.Write, FileShare.None)) - using (var sw = new StreamWriter(fs, encoding)) - { - sw.Write(contents); - sw.Flush(); - fs.Flush(flushToDisk: true); - } - File.Move(tmp, path, overwrite: true); - } - catch - { - try - { - if (File.Exists(tmp)) - { - File.Delete(tmp); - } - } - catch - { - // Best-effort cleanup; surface original error. - } - throw; - } - } - - // Async variant of . Same staging / - // flush-to-disk / rename semantics, but the write itself is async so - // callers in the workspace setup pipeline don't block on disk IO. - // Supports cancellation while staging (cleanup still runs). + // leaving the file truncated or empty. Supports cancellation while + // staging (cleanup still runs). public static async Task AtomicWriteAllTextAsync( string path, string contents, diff --git a/src/winapp-CLI/WinApp.Cli/Services/ConfigService.cs b/src/winapp-CLI/WinApp.Cli/Services/ConfigService.cs index 494ef503..dad7f7a0 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/ConfigService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/ConfigService.cs @@ -2,19 +2,12 @@ // Licensed under the MIT License. using System.Text; -using WinApp.Cli.Helpers; using WinApp.Cli.Models; namespace WinApp.Cli.Services; -// Thin file-I/O wrapper around WinappConfigDocument. The YAML grammar -// (parsing, splicing, rendering) lives in WinappConfigDocument so this -// service stays small and grammar evolutions don't leak into the -// service-surface tests. internal sealed class ConfigService : IConfigService { - private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); - public FileInfo ConfigPath { get; set; } public ConfigService(ICurrentDirectoryProvider currentDirectoryProvider) @@ -25,11 +18,6 @@ public ConfigService(ICurrentDirectoryProvider currentDirectoryProvider) public bool Exists() { - // Guard BEFORE probing the filesystem: ConfigPath.Exists internally - // hits FindFirstFile which on a symlinked / UNC ancestor would - // negotiate SMB (NTLM leak) before we could refuse. Run the - // string-only containment + reparse check first. - GuardConfigPath(); ConfigPath.Refresh(); return ConfigPath.Exists; } @@ -41,35 +29,63 @@ public WinappConfig Load() return new WinappConfig(); } - GuardConfigPath(); var text = File.ReadAllText(ConfigPath.FullName); - return WinappConfigDocument.Parse(text).Config; + return Parse(text); } public void Save(WinappConfig cfg) { - GuardConfigPath(); - // Full serialization — drops comments / unknown fields. - var yaml = new WinappConfigDocument(cfg).Render(); - PathSafety.AtomicWriteAllText(ConfigPath.FullName, yaml, Utf8NoBom); + var yaml = Stringify(cfg); + File.WriteAllText(ConfigPath.FullName, yaml, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); ConfigPath.Refresh(); } - // Refuse to read or rewrite winapp.yaml if the file (or any directory - // between it and its config-dir) is a symlink/junction — a malicious - // workspace could otherwise redirect the I/O at an arbitrary file on - // disk (e.g. clobbering a victim project's config). The config-dir - // itself is the containment boundary because `--config-dir` legitimately - // points outside the base workspace. - private void GuardConfigPath() + private static WinappConfig Parse(string yaml) + { + var cfg = new WinappConfig(); + using var sr = new StringReader(yaml); + string? line; + string? currentName = null; + while ((line = sr.ReadLine()) != null) + { + var t = line.Trim(); + if (t.StartsWith('#') || t.Length == 0) + { + continue; + } + + if (t.Equals("packages:", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (t.StartsWith("- name:", StringComparison.OrdinalIgnoreCase)) + { + currentName = t.Substring("- name:".Length).Trim().Trim('"', '\''); + } + else if (t.StartsWith("name:", StringComparison.OrdinalIgnoreCase)) + { + currentName = t.Substring("name:".Length).Trim().Trim('"', '\''); + } + else if (t.StartsWith("version:", StringComparison.OrdinalIgnoreCase) && currentName is not null) + { + var version = t.Substring("version:".Length).Trim().Trim('"', '\''); + cfg.Packages.Add(new PackagePin { Name = currentName, Version = version }); + currentName = null; + } + } + return cfg; + } + + private static string Stringify(WinappConfig cfg) { - var boundary = ConfigPath.DirectoryName ?? Directory.GetCurrentDirectory(); - if (PathSafety.HasReparsePointOnPath(ConfigPath.FullName, boundary)) + var sb = new StringBuilder(); + sb.AppendLine("packages:"); + foreach (var p in cfg.Packages) { - throw new InvalidOperationException( - $"Refusing to access '{ConfigPath.FullName}': the file or one of its " - + "ancestors up to the config directory is a symbolic link / reparse " - + "point. Resolve the link and re-run."); + sb.AppendLine($" - name: {p.Name}"); + sb.AppendLine($" version: {p.Version}"); } + return sb.ToString(); } -} \ No newline at end of file +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/MsixService.cs b/src/winapp-CLI/WinApp.Cli/Services/MsixService.cs index 73543ecc..e76b3892 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/MsixService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/MsixService.cs @@ -815,18 +815,23 @@ internal static bool IsRuntimeToolExecutable(string fileName) } /// - /// Checks a single directory for a manifest file. Delegates to - /// so the probe order is - /// driven by one source of truth — keeping winapp run / - /// create-debug-identity in lock-step with every other site - /// that locates manifests (cert generate, the build NuGet - /// targets, etc.). Returns when no manifest - /// file is present so callers can branch on "found vs. not". + /// Checks a single directory for a manifest file (Package.appxmanifest or appxmanifest.xml). /// internal static FileInfo? FindManifestInDirectory(DirectoryInfo directory) { - var found = ManifestHelper.FindManifest(directory.FullName); - return found.Exists ? found : null; + var packageManifest = new FileInfo(Path.Combine(directory.FullName, "Package.appxmanifest")); + if (packageManifest.Exists) + { + return packageManifest; + } + + var appxManifest = new FileInfo(Path.Combine(directory.FullName, "appxmanifest.xml")); + if (appxManifest.Exists) + { + return appxManifest; + } + + return null; } /// diff --git a/src/winapp-CLI/WinApp.Cli/Services/WinappConfigDocument.cs b/src/winapp-CLI/WinApp.Cli/Services/WinappConfigDocument.cs deleted file mode 100644 index 19b5dcea..00000000 --- a/src/winapp-CLI/WinApp.Cli/Services/WinappConfigDocument.cs +++ /dev/null @@ -1,337 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using System.Text; -using WinApp.Cli.Models; - -namespace WinApp.Cli.Services; - -/// -/// Hand-rolled reader / writer for the small winapp.yaml grammar that the -/// native CLI owns (packages:). Pure data class — no DI, no file I/O. -/// -/// Mirrors : is -/// the thin file-I/O wrapper; this class owns the YAML grammar. Splitting -/// keeps grammar changes (which evolve with the schema) from leaking into -/// the service surface tests assert against. -/// -/// Unknown top-level keys are silently ignored on read and dropped on a full -/// . Callers that need to round-trip an unknown block -/// (e.g. tooling that layers extra metadata into winapp.yaml) must avoid -/// Save() and read/rewrite the raw yaml text themselves. -/// -internal sealed class WinappConfigDocument -{ - public WinappConfig Config { get; } - - public WinappConfigDocument(WinappConfig config) - { - Config = config ?? throw new ArgumentNullException(nameof(config)); - } - - /// - /// Parse a winapp.yaml document. Unknown top-level fields are silently - /// ignored so adding fields server-side doesn't break older CLIs. - /// - public static WinappConfigDocument Parse(string yaml) - { - return new WinappConfigDocument(ParseInternal(yaml ?? string.Empty)); - } - - /// - /// Full re-serialization. Drops comments and unknown fields. - /// - public string Render() => Stringify(Config); - - // ------------------------------------------------------------------------- - // Parse - // ------------------------------------------------------------------------- - - private static WinappConfig ParseInternal(string yaml) - { - var cfg = new WinappConfig(); - using var sr = new StringReader(yaml); - string? line; - string? currentName = null; - var section = Section.None; - - while ((line = sr.ReadLine()) != null) - { - // Preserve raw indent for nested-list tracking, then trim for content match. - var indent = LeadingSpaceCount(line); - var t = line.Trim(); - if (t.StartsWith('#') || t.Length == 0) - { - continue; - } - - // Top-level section switches (no indent). - if (indent == 0) - { - if (IsTopLevelKey(t, "packages:")) - { - section = Section.Packages; - currentName = null; - continue; - } - - // Unknown top-level field → reset section so children don't leak - // into packages/etc. - section = Section.None; - currentName = null; - continue; - } - - switch (section) - { - case Section.Packages: - if (t.StartsWith("- name:", StringComparison.OrdinalIgnoreCase)) - { - currentName = SanitizeScalar(t.Substring("- name:".Length)); - } - else if (t.StartsWith("name:", StringComparison.OrdinalIgnoreCase)) - { - currentName = SanitizeScalar(t.Substring("name:".Length)); - } - else if (currentName is not null && t.StartsWith("version:", StringComparison.OrdinalIgnoreCase)) - { - var version = SanitizeScalar(t.Substring("version:".Length)); - cfg.Packages.Add(new PackagePin { Name = currentName, Version = version }); - currentName = null; - } - break; - } - } - - return cfg; - } - - internal static bool TryReadScalar(string t, string prefix, out string value) - { - if (t.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - { - value = SanitizeScalar(t.Substring(prefix.Length)); - return true; - } - value = string.Empty; - return false; - } - - // YAML-style boolean tolerance: true/false/yes/no/on/off (case-insensitive). - internal static bool TryParseBool(string value, out bool result) - { - switch (value.Trim().ToLowerInvariant()) - { - case "true": - case "yes": - case "on": - case "1": - result = true; - return true; - case "false": - case "no": - case "off": - case "0": - result = false; - return true; - default: - result = false; - return false; - } - } - - // Trims surrounding whitespace, strips an unquoted trailing `# comment`, - // then strips a single pair of matching surrounding quotes. Mirrors what - // a YAML parser would do for plain / single- / double-quoted scalars - // (sufficient for the small grammar we parse by hand). - // - // `version: 1.0.0 # pinned` → `1.0.0` - // `name: "weird # name"` → `weird # name` - // `name: 'O''Brien'` → `O'Brien` - internal static string SanitizeScalar(string raw) - { - if (string.IsNullOrEmpty(raw)) - { - return string.Empty; - } - - var trimmed = raw.AsSpan().TrimStart(); - char? quoteOpener = null; - if (trimmed.Length > 0 && (trimmed[0] == '"' || trimmed[0] == '\'')) - { - quoteOpener = trimmed[0]; - } - - int cutoff = trimmed.Length; - // Quote-state tracking only matters when the scalar is actually - // quoted (i.e. `quoteOpener` was set at index 0). For plain - // scalars, an apostrophe inside a value like `John's` must NOT - // make the rest of the line look "inside a single quote" — that - // would suppress the `# comment` boundary and a subsequent save - // would re-quote the value with the comment baked in. - bool trackQuoteState = quoteOpener is not null; - bool inSingle = false; - bool inDouble = false; - for (int i = 0; i < trimmed.Length; i++) - { - var c = trimmed[i]; - if (trackQuoteState) - { - if (inDouble) - { - if (c == '\\' && i + 1 < trimmed.Length) { i++; continue; } - if (c == '"') { inDouble = false; } - continue; - } - if (inSingle) - { - if (c == '\'') { inSingle = false; } - continue; - } - if (c == '"') { inDouble = true; continue; } - if (c == '\'') { inSingle = true; continue; } - } - if (c == '#') - { - // YAML requires whitespace before an unquoted inline comment. - if (i == 0 || char.IsWhiteSpace(trimmed[i - 1])) - { - cutoff = i; - break; - } - } - } - - var value = trimmed.Slice(0, cutoff).TrimEnd(); - // Only peel the OUTER quote pair when it's symmetrical so we don't - // turn `it's` into `it`s`. - if (value.Length >= 2 && quoteOpener is char q && value[0] == q && value[^1] == q) - { - var inner = value.Slice(1, value.Length - 2).ToString(); - if (q == '\'') - { - // YAML single-quoted scalars use `''` as the escape for a - // literal `'`. Render writes `'O''Brien'` for `O'Brien`; - // unescape symmetrically so round-trip is stable. - return inner.Replace("''", "'"); - } - return inner; - } - return value.ToString(); - } - - private static int LeadingSpaceCount(string line) - { - int i = 0; - while (i < line.Length && line[i] == ' ') - { - i++; - } - return i; - } - - // Matches a top-level key like `packages:` with any trailing whitespace - // or inline `# comment`. - internal static bool IsTopLevelKey(string trimmedLine, string key) - { - if (!trimmedLine.StartsWith(key, StringComparison.OrdinalIgnoreCase)) - { - return false; - } - if (trimmedLine.Length == key.Length) - { - return true; - } - var rest = trimmedLine.AsSpan(key.Length).TrimStart(); - return rest.IsEmpty || rest[0] == '#'; - } - - private enum Section { None, Packages } - - // ------------------------------------------------------------------------- - // Render - // ------------------------------------------------------------------------- - - private static string Stringify(WinappConfig cfg) - { - var sb = new StringBuilder(); - sb.AppendLine("packages:"); - foreach (var p in cfg.Packages) - { - sb.AppendLine($" - name: {QuoteScalar(p.Name)}"); - sb.AppendLine($" version: {QuoteScalar(p.Version)}"); - } - - return sb.ToString(); - } - - // Quote a YAML scalar with single quotes when the raw value would be - // re-parsed incorrectly by our (or any other) plain-scalar reader. We - // bias toward over-quoting because the cost is cosmetic and the cost - // of UNDER-quoting is silent data corruption on the next load (e.g. - // a Windows path `C:\foo` would otherwise be re-read as a mapping, a - // value containing `#` would be re-read with the comment chopped off). - internal static string QuoteScalar(string value) - { - if (NeedsQuoting(value)) - { - // Single-quoted YAML strings: only `'` needs escaping (doubled). - return "'" + value.Replace("'", "''") + "'"; - } - return value; - } - - private static bool NeedsQuoting(string value) - { - if (string.IsNullOrEmpty(value)) - { - return true; - } - if (char.IsWhiteSpace(value[0]) || char.IsWhiteSpace(value[^1])) - { - return true; - } - // YAML indicators that cannot start a plain scalar (YAML 1.2 §7.3.3). - if ("-?:,[]{}#&*!|>'\"%@`".IndexOf(value[0]) >= 0) - { - return true; - } - // Reserved YAML 1.1 boolean/null literals (parsers may still honor these). - switch (value.ToLowerInvariant()) - { - case "null": - case "~": - case "true": - case "false": - case "yes": - case "no": - case "on": - case "off": - return true; - } - // Numeric-looking values would re-parse as numbers, not strings. - if (long.TryParse(value, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out _)) - { - return true; - } - if (double.TryParse(value, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out _)) - { - return true; - } - foreach (var c in value) - { - if (c == '\t' || c == '\r' || c == '\n') - { - return true; - } - // Any `:` or `#` in the body is enough to change the parse. Windows - // paths (`C:\…`) and values like `note # foo` are the motivating - // cases — be conservative and quote. - if (c == ':' || c == '#') - { - return true; - } - } - return false; - } -} diff --git a/src/winapp-npm/package-lock.json b/src/winapp-npm/package-lock.json index 05df349c..8848f428 100644 --- a/src/winapp-npm/package-lock.json +++ b/src/winapp-npm/package-lock.json @@ -13,7 +13,7 @@ "win32" ], "dependencies": { - "@microsoft/dynwinrt-codegen": "0.1.0-preview.1" + "@microsoft/dynwinrt-codegen": "0.1.0-preview.2" }, "bin": { "winapp": "dist/cli.js" @@ -200,9 +200,9 @@ } }, "node_modules/@microsoft/dynwinrt-codegen": { - "version": "0.1.0-preview.1", - "resolved": "https://registry.npmjs.org/@microsoft/dynwinrt-codegen/-/dynwinrt-codegen-0.1.0-preview.1.tgz", - "integrity": "sha512-pyVb/xAXRwTf8WNkIqccm/CriHtAio09vZYv3kg/5pjgNVh0WkNogvd2Sup1rloyi5pobgAbd85DhedGa+/8bQ==", + "version": "0.1.0-preview.2", + "resolved": "https://registry.npmjs.org/@microsoft/dynwinrt-codegen/-/dynwinrt-codegen-0.1.0-preview.2.tgz", + "integrity": "sha512-pIJwYbFug4QUIcnbTL3z2wUZh0qdWtYODTH0H8GjZ3Neku6DgwH6IMo+pXx0drimAtAaXR05ttB1AALidIN7+w==", "license": "MIT", "os": [ "win32" diff --git a/src/winapp-npm/package.json b/src/winapp-npm/package.json index a32b5cf7..c05ae1f6 100644 --- a/src/winapp-npm/package.json +++ b/src/winapp-npm/package.json @@ -52,7 +52,7 @@ "win32" ], "dependencies": { - "@microsoft/dynwinrt-codegen": "0.1.0-preview.1" + "@microsoft/dynwinrt-codegen": "0.1.0-preview.2" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/src/winapp-npm/scripts/generate-commands.mjs b/src/winapp-npm/scripts/generate-commands.mjs index 28475717..e4bc8ac2 100644 --- a/src/winapp-npm/scripts/generate-commands.mjs +++ b/src/winapp-npm/scripts/generate-commands.mjs @@ -73,18 +73,10 @@ function kebabToPascal(s) { return cc.charAt(0).toUpperCase() + cc.slice(1); } -// Clean up CLI description for JSDoc: single line, escape `*/` (closes the -// JSDoc) and `@` (truncates description in TS doc extractors). Escape `\` -// first so the escape sequences we introduce below aren't double-processed. +/** Clean up CLI description for JSDoc (single line, no trailing period). */ function cleanDesc(desc) { if (!desc) return ''; - return desc - .replace(/\r?\n/g, ' ') - .replace(/\s+/g, ' ') - .trim() - .replace(/\\/g, '\\\\') - .replace(/\*\//g, '*\\/') - .replace(/@/g, '\\@'); + return desc.replace(/\r?\n/g, ' ').replace(/\s+/g, ' ').trim(); } const COMMON_OPTIONS = new Set(['--quiet', '--verbose', '--help']); @@ -256,7 +248,7 @@ function generate(schema) { for (const { path: cmdPath, cmd } of commands) { const cmdPathStr = cmdPath.join(' '); const fnName = getFunctionName(cmdPath); - const ifaceName = getInterfaceName(cmdPath); + const ifaceName = kebabToPascal(cmdPath.join('-')) + 'Options'; // Check for passthrough command const passthrough = PASSTHROUGH_COMMANDS[cmdPath.join(' ')] || null; @@ -365,8 +357,6 @@ const FN_NAME_OVERRIDES = { 'package': 'packageApp', // `package` is a TS reserved-ish word }; -const IFACE_NAME_OVERRIDES = {}; - function getFunctionName(cmdPath) { const key = cmdPath.join(' '); if (FN_NAME_OVERRIDES[key]) return FN_NAME_OVERRIDES[key]; @@ -376,12 +366,6 @@ function getFunctionName(cmdPath) { return TS_RESERVED.has(name) ? name + 'Command' : name; } -function getInterfaceName(cmdPath) { - const key = cmdPath.join(' '); - if (IFACE_NAME_OVERRIDES[key]) return IFACE_NAME_OVERRIDES[key]; - return kebabToPascal(cmdPath.join('-')) + 'Options'; -} - // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- From 9f2625f8930a493ac39ad8ae6d39521b795d1075 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Fri, 22 May 2026 23:35:32 +0800 Subject: [PATCH 19/27] simplfy the comments --- .github/plugin/agents/winapp.agent.md | 3 +- docs/guides/electron/js-bindings.md | 4 +- docs/usage.md | 32 ++-- samples/electron/test.Tests.ps1 | 23 +-- .../WinApp.Cli/Helpers/PathSafety.cs | 81 +++------- .../Services/WinmdsLockfileService.cs | 36 ++--- .../src/jsbindings/additional-winmds.ts | 24 +-- .../src/jsbindings/codegen-runner.ts | 152 ++++-------------- src/winapp-npm/src/jsbindings/init-prompt.ts | 72 ++------- .../src/jsbindings/lockfile-reader.ts | 50 ++---- src/winapp-npm/src/jsbindings/orchestrator.ts | 70 ++------ .../src/jsbindings/package-json-config.ts | 107 ++---------- 12 files changed, 145 insertions(+), 509 deletions(-) diff --git a/.github/plugin/agents/winapp.agent.md b/.github/plugin/agents/winapp.agent.md index 0246d088..ada32c36 100644 --- a/.github/plugin/agents/winapp.agent.md +++ b/.github/plugin/agents/winapp.agent.md @@ -223,8 +223,9 @@ Want to inspect or interact with a running app's UI? - **Re-run:** `npx winapp node generate-bindings` after editing `winapp.jsBindings.{packages,extraTypes,additionalWinmds}`. Use `npx winapp restore` instead when you changed `winapp.yaml`. - Codegen injects `@microsoft/dynwinrt` as a production dep — run `npm install` afterwards to materialize it. - **Native addons:** `winapp node create-addon --template cs` (or `--template cpp`) for C#/C++ addons when you need full WinRT access or stateful native services. - - Then: `winapp node add-electron-debug-identity` to enable identity-required APIs. - **Package:** Build with your packager (e.g., Electron Forge), then `winapp package --cert .\devcert.pfx` +- Use `winapp node create-addon` to create native C#/C++ addons for Windows APIs +- Use `winapp node add-electron-debug-identity` / `clear-electron-debug-identity` for identity management - **⚠️ Always run `npx winapp node add-electron-debug-identity` before testing any Windows API that requires package identity** — without this, APIs will fail at runtime - Guide: https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/setup.md - JS bindings reference: https://github.com/microsoft/WinAppCli/blob/main/docs/js-bindings.md diff --git a/docs/guides/electron/js-bindings.md b/docs/guides/electron/js-bindings.md index 51b2d18e..06bffc7e 100644 --- a/docs/guides/electron/js-bindings.md +++ b/docs/guides/electron/js-bindings.md @@ -415,9 +415,9 @@ After a successful generation winapp writes `/.dynwinrt-managed` into th In addition, `winapp restore` writes `.winapp/winmds.lock.json` — a human-readable snapshot of every NuGet package the restore resolved, with its version and the per-package winmd discovery results. The lockfile is the bridge between the native `winapp restore` (which writes it) and the npm wrapper (which reads it and applies the emit/refOnly/skip policy at codegen time). It's also a useful diagnostic artifact: - Useful to share when reporting bugs: it tells the maintainer exactly what got resolved without us needing to repro your NuGet feed setup. -- Records a SHA-256 of the top-level `packages:` block so you can spot yaml drift between restore runs. +- Records a SHA-256 hash over sorted `lower(name)|version` lines from your yaml `packages:` block — a canonical fingerprint of the resolved set, so the npm wrapper can spot yaml drift between restore runs. -**Write atomicity**: lockfile writes go through a per-call `.tmp.` sibling that's renamed into place on completion, so concurrent readers always see a consistent (old or new) file, never a torn write. +**Write atomicity**: lockfile writes go through a per-call `.tmp-` sibling that's renamed into place on completion, so concurrent readers always see a consistent (old or new) file, never a torn write. ## Next steps diff --git a/docs/usage.md b/docs/usage.md index 4d5d6aae..f9bff3b6 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -36,17 +36,11 @@ winapp init [base-directory] [options] - `--use-defaults`, `--no-prompt` - Do not prompt, and use default of all prompts - `--config-only` - Only handle configuration file operations, skip package installation -**JS/TypeScript bindings (npm only):** +**JS/TypeScript bindings (npm wrapper only):** -When invoked via the `@microsoft/winappcli` npm package (i.e. `npx winapp init` inside a Node / Electron project), `init` adds an interactive **bindings prompt**: +When run via `npx winapp` in a Node / Electron project, `init` adds an interactive prompt — answering **Y** (the default) writes a `"winapp.jsBindings"` namespace to `package.json` and runs codegen. -``` -Add JS/TypeScript bindings to this project? [Y/n]: -``` - -Picking **Yes** writes a default `"winapp.jsBindings"` namespace to `package.json` and runs `dynwinrt-codegen` to emit JS/TS wrappers. C++ projections (cppwinrt headers/libs/runtimes) are generated either way — there is no "JS only" mode; the JS bindings are an addition on top of the standard native workspace. Subsequent `winapp restore` calls re-run codegen against the pinned packages, or use `winapp node generate-bindings` for fast codegen-only re-runs after editing `winapp.jsBindings`. - -See the [Electron JS bindings guide](guides/electron/js-bindings.md) for the full schema (`packages`, `skip`, `refOnly`, `extraTypes`, etc.) and the end-to-end workflow. +See the [JS/TypeScript bindings section](#jstypescript-bindings-via-init--restore) below for the full command matrix and the [Electron JS bindings guide](guides/electron/js-bindings.md) for the schema and end-to-end workflow. **What it does:** @@ -161,26 +155,18 @@ winapp update --setup-sdks experimental ### JS/TypeScript bindings (via `init` / `restore`) -JS/TS bindings are configured by declaring a `"winapp": { "jsBindings": {...} }` namespace in **`package.json`** and generated by `winapp init` (first run) or `winapp restore` (subsequent runs). There is no separate `node jsbindings` sub-command — the flow is unified with the rest of the workspace lifecycle: +Declare JS/TS bindings with a `"winapp": { "jsBindings": {...} }` namespace in **`package.json`**. They're generated alongside the workspace lifecycle — no dedicated sub-command. | Want to … | Command | |---|---| | Bootstrap a fresh workspace with bindings | `npx winapp init` (answer **Y** at the prompt; default is **Y**) | -| Add JS bindings to an existing workspace | `npx winapp node generate-bindings` — adds a default `"winapp": { "jsBindings": {} }` block (full-SDK scope) on first use, then generates from the cached winmd lockfile. Requires a prior `winapp restore`. | -| Re-run codegen after editing `winapp.jsBindings` (fast path) | `npx winapp node generate-bindings` | -| Re-run codegen as part of full restore (also handles NuGet + cppwinrt) | `npx winapp restore` | - -> **Which one should I run?** `winapp node generate-bindings` only re-runs `dynwinrt-codegen` using the cached `.winapp/winmds.lock.json` — it's the right choice after editing only the `winapp.jsBindings` block in `package.json`. Use `winapp restore` whenever you change `winapp.yaml` (packages, sdkVersion, etc.) so the lockfile is refreshed first. - -**What runs during `restore` when `winapp.jsBindings` is declared:** +| Add bindings to an existing workspace | `npx winapp node generate-bindings` (adds a default `winapp.jsBindings` block on first use) | +| Re-run codegen after editing `winapp.jsBindings` | `npx winapp node generate-bindings` — fast, codegen-only | +| Re-run codegen after editing `winapp.yaml` | `npx winapp restore` — also re-resolves NuGet packages | -- Reads the existing `winapp.jsBindings` namespace from `package.json` (no mutation) -- Resolves winmds via NuGet cache walk + transitive-deps expansion -- Spawns `@microsoft/dynwinrt-codegen` to emit `.js` + `.d.ts` into the configured `output` directory (default `bindings/`) -- Replaces the previous output dir atomically (stage-then-swap); previous bindings are preserved on codegen failure -- Auto-injects `@microsoft/dynwinrt` as a production dep in your `package.json` so generated bindings can `import` it at runtime +Each generation auto-injects `@microsoft/dynwinrt` as a production dependency in your `package.json` so the emitted bindings can `import` it at runtime. -JS/TS bindings are **npm-only** — they require invocation via the `@microsoft/winappcli` npm package because they pull in `@microsoft/dynwinrt-codegen` as a transitive dep. The interactive bindings prompt during `init` only fires when invoked via the npm shim (`npx winapp …`); the standalone winget CLI does not surface it. +Bindings are **npm-only** — they require invocation via `npx winapp` (the `@microsoft/winappcli` npm package); the standalone winget CLI does not surface them. > See [JS bindings guide](guides/electron/js-bindings.md) for the full `winapp.jsBindings` schema, per-package winmd categorization (skip / refOnly / emit overrides), and the `winmds.lock.json` audit artifact. diff --git a/samples/electron/test.Tests.ps1 b/samples/electron/test.Tests.ps1 index eeae46f4..cbf01711 100644 --- a/samples/electron/test.Tests.ps1 +++ b/samples/electron/test.Tests.ps1 @@ -111,13 +111,6 @@ Describe "Electron Sample" { } It "Should initialize winapp workspace with JS bindings and C++ projections" -Skip:$script:skip { - # `init --use-defaults` invoked via the npm shim auto-answers Yes - # at the bindings prompt (Add JS/TypeScript bindings? [Y/n]) and - # runs codegen in one step. C++ projections always run. The - # prompt only fires when WINAPP_CLI_CALLER=nodejs-package (set by - # the `npx winapp` shim, which Invoke-WinappCommand resolves to - # here after Install-WinappNpmPackage). Selecting Yes writes - # `"winapp": { "jsBindings": {} }` into package.json. Push-Location $script:appDir try { Invoke-WinappCommand -Arguments "init . --use-defaults --setup-sdks=stable" @@ -131,25 +124,18 @@ Describe "Electron Sample" { } # ── JS bindings smoke (v2.x) ───────────────────────────────────── - # Verify the npm-caller init path produced the expected bindings - # output, lockfile, and runtime dep — and that re-running `restore` - # is idempotent (no winapp.yaml or package.json mutation). It "Should have generated bindings/ with the managed marker" -Skip:$script:skip { $bindingsDir = Join-Path $script:appDir "bindings" $bindingsDir | Should -Exist - # Marker proves the staging-then-swap completed. (Join-Path $bindingsDir ".dynwinrt-managed") | Should -Exist - # Full WinAppSDK generates hundreds of .js files; assert a - # generous lower bound to catch the "0 files generated" regression - # without being brittle to upstream SDK changes. + # 50 is a generous lower bound for the full WinAppSDK scope; catches + # "0 files generated" regressions without being brittle to SDK updates. $jsCount = (Get-ChildItem -Path $bindingsDir -Filter '*.js' -ErrorAction SilentlyContinue).Count $jsCount | Should -BeGreaterThan 50 -Because "Default jsBindings (full WinAppSDK) should generate many JS files" } It "Should inject @microsoft/dynwinrt as a runtime dep in package.json" -Skip:$script:skip { - # Bindings import @microsoft/dynwinrt at load time — must be a - # production dep so `npm ci --omit=dev` doesn't strip it. $pkgPath = Join-Path $script:appDir "package.json" $pkg = Get-Content $pkgPath -Raw | ConvertFrom-Json $pkg.dependencies.'@microsoft/dynwinrt' | Should -Not -BeNullOrEmpty ` @@ -157,8 +143,6 @@ Describe "Electron Sample" { } It "Should write a winmds.lock.json under .winapp/" -Skip:$script:skip { - # Seeded by restore (during init); diagnostic record of the - # winmd → package mapping at codegen time. $lockfilePath = Join-Path $script:appDir ".winapp\winmds.lock.json" $lockfilePath | Should -Exist $lockfile = Get-Content $lockfilePath -Raw | ConvertFrom-Json @@ -167,9 +151,6 @@ Describe "Electron Sample" { } It "Should re-run codegen via 'winapp restore' without mutating winapp.yaml or jsBindings" -Skip:$script:skip { - # `restore` is the read-only re-run path — it must not modify - # winapp.yaml or the winapp.jsBindings namespace in package.json. - # Capture both hashes before/after to prove it. $yamlPath = Join-Path $script:appDir "winapp.yaml" $pkgPath = Join-Path $script:appDir "package.json" $bindingsDir = Join-Path $script:appDir "bindings" diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/PathSafety.cs b/src/winapp-CLI/WinApp.Cli/Helpers/PathSafety.cs index 3af3f6fc..21ad17c8 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/PathSafety.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/PathSafety.cs @@ -5,33 +5,21 @@ namespace WinApp.Cli.Helpers; -// Shared filesystem-safety helpers. Centralizing the reparse-point / -// containment check keeps every "write into the user's workspace" site -// (e.g. WinmdsLockfileService) consistent — we don't want one to drift -// behind the others. +// Filesystem-safety helpers shared by every "write into the user's workspace" +// site (e.g. WinmdsLockfileService). internal static class PathSafety { - // True if `path` is not safely contained under `boundary`, or if any - // segment from `boundary` down to `path` is a reparse point, or if - // either side is a UNC path. Used to refuse rewriting / probing files - // that a hostile workspace could redirect via a symlink/junction to a - // victim location elsewhere on the machine. + // True if `path` is not safely contained under `boundary`, if any segment + // from `boundary` down to `path` is a reparse point, or if either side is + // a UNC path. // - // Implementation notes: - // * Walks DOWN from `boundary` instead of UP from `path`. Walking up - // would force the OS to traverse any junctions / symlinks in `path` - // to look up the leaf's attributes, which on Windows can trigger - // SMB negotiation (and NTLM leak) before we ever see the - // reparse-point flag. Walking down lets us reject as soon as a - // suspicious segment is observed, without ever probing past it. - // * Uses `File.GetAttributes` rather than `FileInfo.Exists` / - // `DirectoryInfo.Exists`; the latter call FindFirstFile internally, - // which on a UNC ancestor would also probe the network before the - // reparse-point flag can be inspected. - // * UNC inputs are rejected outright (long-path `\\?\C:\…` is fine; - // `\\server\share` and `\\?\UNC\…` are not). - // * Missing segments are skipped (no I/O), so a caller about to - // create the file still passes the guard. + // Walks DOWN from `boundary` (not UP from `path`): walking up forces the + // OS to traverse symlinks/junctions in `path` to look up the leaf's + // attributes, which on Windows can trigger SMB negotiation (and NTLM + // leak) before the reparse-point flag is visible. Uses File.GetAttributes + // (FileInfo.Exists / DirectoryInfo.Exists call FindFirstFile internally, + // same SMB-probe hazard). Missing segments are skipped — callers about to + // create the file still pass the guard. public static bool HasReparsePointOnPath(string path, string boundary) { string fullPath; @@ -54,10 +42,8 @@ public static bool HasReparsePointOnPath(string path, string boundary) var normalizedBoundary = NormalizeForContainment(fullBoundary); var normalizedPath = NormalizeForContainment(fullPath); - // Containment (string-only — no I/O). The boundary itself is a - // valid target (path == boundary), otherwise path must live under - // boundary + a separator. Boundary may already end in a separator - // (drive root, e.g. `C:\`) — don't double up. + // String-only containment. Boundary itself is a valid target; + // otherwise path must live under boundary + a separator. bool isBoundaryItself = string.Equals( normalizedPath, normalizedBoundary, @@ -73,9 +59,8 @@ public static bool HasReparsePointOnPath(string path, string boundary) return true; } - // Check the boundary itself FIRST. If the boundary is a reparse - // point, every descendant probe would silently follow it; refuse - // before we ever touch a descendant path. + // Check boundary itself first — a reparse-point boundary would make + // every descendant probe silently follow it. if (TryGetAttributes(normalizedBoundary, out var boundaryAttr) && boundaryAttr.HasFlag(FileAttributes.ReparsePoint)) { @@ -87,10 +72,6 @@ public static bool HasReparsePointOnPath(string path, string boundary) return false; } - // Walk DOWN from boundary one segment at a time. The remainder - // after the boundary cannot contain `..` (Path.GetFullPath - // normalised it) so each segment is a literal directory / file - // name. var remainder = normalizedPath.Substring(normalizedBoundary.Length); var segments = remainder.Split( new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, @@ -105,7 +86,6 @@ public static bool HasReparsePointOnPath(string path, string boundary) { return true; } - // Missing segments are fine — we don't refuse on absence. } return false; @@ -113,8 +93,6 @@ public static bool HasReparsePointOnPath(string path, string boundary) // True for UNC / network paths (`\\server\share`, `\\?\UNC\…`, // `\\.\UNC\…`). Local DOS device paths (`\\?\C:\…`) are not network. - // Centralized here so every caller shares the same definition of - // "a path that would trigger an SMB probe / NTLM leak". public static bool IsNetworkPath(string path) { if (string.IsNullOrEmpty(path)) @@ -124,8 +102,7 @@ public static bool IsNetworkPath(string path) var p = path.Replace('/', '\\'); - // Plain UNC: \\server\share… (server is non-empty, not a device - // designator like '?' or '.'). + // Plain UNC: \\server\share… if (p.Length >= 3 && p[0] == '\\' && p[1] == '\\' && p[2] != '?' && p[2] != '.') @@ -133,7 +110,7 @@ public static bool IsNetworkPath(string path) return true; } - // Device-prefixed UNC: \\?\UNC\server\… or \\.\UNC\server\… + // Device-prefixed UNC: \\?\UNC\… or \\.\UNC\… if (p.Length >= 8 && p[0] == '\\' && p[1] == '\\' && (p[2] == '?' || p[2] == '.') @@ -149,13 +126,10 @@ public static bool IsNetworkPath(string path) return false; } - // Trims trailing separators but preserves the root separator for a - // bare drive designator. `C:\` would otherwise collapse to `C:` (a - // drive-relative reference) and the descent loop would then call - // Path.Combine("C:", seg) — yielding "C:foo" (drive-relative, resolved - // against the per-drive CWD) instead of "C:\foo". That silently - // bypasses the reparse-point check for any workspace/config-dir - // rooted at a drive letter. + // Trims trailing separators but preserves the root separator on bare + // drive designators. `C:\` collapsed to `C:` would make Path.Combine + // produce drive-relative paths (`C:foo` → resolved against the per-drive + // CWD), silently bypassing the reparse-point check. private static string NormalizeForContainment(string path) { var trimmed = TrimTrailingSeparators(path); @@ -188,19 +162,14 @@ private static bool TryGetAttributes(string path, out FileAttributes attributes) } catch { - // Any other error (access denied, IO, etc.): treat as "no - // attributes available". Callers can still refuse the - // operation when they hit the actual read/write. attributes = default; return false; } } - // Write `contents` to `path` atomically: stage to a sibling temp file - // (same volume so the move stays atomic), flush to disk, then rename - // over the destination. Prevents a crash / power loss mid-write from - // leaving the file truncated or empty. Supports cancellation while - // staging (cleanup still runs). + // Stage to a sibling temp file (same volume so the move stays atomic), + // flush to disk, rename over the destination. Prevents a crash mid-write + // from leaving the file truncated. public static async Task AtomicWriteAllTextAsync( string path, string contents, diff --git a/src/winapp-CLI/WinApp.Cli/Services/WinmdsLockfileService.cs b/src/winapp-CLI/WinApp.Cli/Services/WinmdsLockfileService.cs index 493ca31c..c36f0d7b 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/WinmdsLockfileService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/WinmdsLockfileService.cs @@ -17,18 +17,12 @@ internal sealed class WinmdsLockfileService(ILogger logge public FileInfo GetLockfilePath(DirectoryInfo winappDir) => new(Path.Combine(winappDir.FullName, LockfileName)); - // Refuse to read/write the lockfile if `.winapp/` (or any segment of - // its path up to the workspace) is a symlink / junction. The lockfile - // lives next to user-controlled state; a malicious workspace can plant - // `.winapp` as a junction to a UNC share or a victim file before - // winapp ever runs, so we cannot trust the path even though we'd - // normally consider `.winapp/` winapp-managed. + // `.winapp` can be user-planted as a junction/UNC target before winapp runs, + // so the lockfile path is untrusted even though winapp normally manages it. private static bool IsLockfilePathUnsafe(DirectoryInfo winappDir, FileInfo lockfilePath) { - // Use the parent of `.winapp` (i.e. the workspace) as the boundary - // when discoverable. Fall back to `.winapp` itself otherwise (the - // call still flags the dir being a reparse point because PathSafety - // checks the boundary). + // Use the workspace as boundary when discoverable. + // PathSafety checks the boundary too, so `.winapp` itself is covered. var boundary = winappDir.Parent?.FullName ?? winappDir.FullName; return PathSafety.HasReparsePointOnPath(lockfilePath.FullName, boundary); } @@ -46,9 +40,7 @@ public async Task WriteAsync( var path = GetLockfilePath(winappDir); if (IsLockfilePathUnsafe(winappDir, path)) { - // Lockfile is an optimization, not a correctness requirement — - // log + skip rather than throw, so codegen still proceeds via - // live discovery. + // Lockfile is optional; skip unsafe writes so live discovery can proceed. logger.LogDebug( "Skipping winmds lockfile write at {LockfilePath}: .winapp or one of its ancestors is a symlink / reparse point.", path.FullName); @@ -59,8 +51,7 @@ public async Task WriteAsync( var lockfile = BuildLockfile(usedVersions, discoveredWinmds, nugetCacheDir, yamlPackagesHash); var json = JsonSerializer.Serialize(lockfile, WinmdsLockfileJsonContext.Default.WinmdsLockfile); - // Atomic write via the shared PathSafety helper — single source - // of truth for staging + fsync + rename semantics. + // Shared helper owns staging + fsync + rename semantics. await PathSafety.AtomicWriteAllTextAsync( path.FullName, json + "\n", @@ -73,7 +64,7 @@ await PathSafety.AtomicWriteAllTextAsync( } catch (Exception ex) when (ex is not OperationCanceledException) { - // Lockfile is an optimization, not a correctness requirement. + // Lockfile is optional. logger.LogDebug(ex, "Failed to write winmds lockfile (continuing without)"); } } @@ -85,6 +76,7 @@ await PathSafety.AtomicWriteAllTextAsync( var path = GetLockfilePath(winappDir); if (IsLockfilePathUnsafe(winappDir, path)) { + // Unsafe reads fall back to live discovery. logger.LogDebug( "Skipping winmds lockfile read at {LockfilePath}: .winapp or one of its ancestors is a symlink / reparse point.", path.FullName); @@ -131,18 +123,15 @@ await PathSafety.AtomicWriteAllTextAsync( } } - // Bucket winmds by package. Paths off the NuGet cache layout are dropped. - // Classification (emit/refOnly/skip) is intentionally NOT recorded — that - // policy lives in the npm wrapper so changing it doesn't force a native - // CLI rebuild + redeploy. + // Bucket winmds by package; paths off the NuGet cache layout are dropped. + // Classification stays in the npm wrapper so policy changes don't require native redeploy. internal static WinmdsLockfile BuildLockfile( IReadOnlyDictionary usedVersions, IReadOnlyList discoveredWinmds, DirectoryInfo nugetCacheDir, string? yamlPackagesHash = null) { - // NuGet cache layout is lowercase; bucket by lowercased id. Output - // entries keep usedVersions's original casing. + // NuGet cache layout is lowercase; output keeps usedVersions casing. var winmdsByPackage = new Dictionary>(StringComparer.OrdinalIgnoreCase); foreach (var w in discoveredWinmds) { @@ -188,8 +177,7 @@ internal static WinmdsLockfile BuildLockfile( } // NuGet cache layout: `///...`. - // Returns the lowercased package id segment, or null if the file isn't - // under the cache (e.g. a user-supplied `additionalWinmds` path). + // Returns null for user-supplied additionalWinmds outside the cache. private static string? ExtractPackageIdFromPath(string winmdFullPath, string nugetCacheDir) { var normCache = Path.TrimEndingDirectorySeparator(Path.GetFullPath(nugetCacheDir)); diff --git a/src/winapp-npm/src/jsbindings/additional-winmds.ts b/src/winapp-npm/src/jsbindings/additional-winmds.ts index 6d447c71..faa1dec7 100644 --- a/src/winapp-npm/src/jsbindings/additional-winmds.ts +++ b/src/winapp-npm/src/jsbindings/additional-winmds.ts @@ -1,27 +1,17 @@ // Copyright (c) Microsoft Corporation and Contributors. All rights reserved. // Licensed under the MIT License. // -// Resolves entries from `jsBindings.additionalWinmds` / `additionalRefs` to -// absolute file paths, with the same defenses as the original C# code: -// * Reject UNC / network paths before any probe (FileInfo.Exists on a UNC -// would trigger SMB negotiation and leak NTLM). -// * Reject reparse-point ancestors (symlink/junction) — for absolute paths -// under the workspace, boundary = workspace; for absolute paths outside -// the workspace, boundary = the drive root. -// * Silently skip missing files (codegen would just fail anyway). -// * Dedupe by full path, case-insensitive. +// Reject UNC paths before probing to avoid SMB/NTLM leakage. +// Reject reparse-point ancestors; workspace paths use workspace as boundary, +// and absolute paths outside the workspace use the drive root. import * as fs from 'fs'; import * as path from 'path'; import { isNetworkPath, hasReparsePointOnPath } from './path-safety'; /** - * One entry in `winapp.jsBindings.additionalWinmds` (in package.json). - * - * * `winmdPath` alone → bulk-emit the entire winmd - * * `winmdPath` + `namespace` + non-empty `classes` → cherry-pick: only - * emit the listed classes from the namespace (the winmd is loaded as - * ref-only so codegen can resolve its other types if needed). + * Package.json entry; `winmdPath` alone bulk-emits, + * while namespace+classes cherry-picks. */ export interface AdditionalWinmd { winmdPath: string; @@ -29,9 +19,8 @@ export interface AdditionalWinmd { classes?: string[]; } -/** An `AdditionalWinmd` whose `winmdPath` has been resolved + safety-checked. */ export interface ResolvedAdditionalWinmd { - /** Absolute path to a real, on-disk, non-UNC, non-reparse winmd file. */ + /** Absolute path after UNC/reparse checks. */ winmdPath: string; namespace?: string; classes?: string[]; @@ -117,7 +106,6 @@ export function resolveAdditionalWinmds( return { resolved, warnings }; } -/** True when the resolved entry is a cherry-pick (has both namespace + classes). */ export function isCherryPick( entry: ResolvedAdditionalWinmd ): entry is ResolvedAdditionalWinmd & { namespace: string; classes: string[] } { diff --git a/src/winapp-npm/src/jsbindings/codegen-runner.ts b/src/winapp-npm/src/jsbindings/codegen-runner.ts index c99d4db3..445dabb5 100644 --- a/src/winapp-npm/src/jsbindings/codegen-runner.ts +++ b/src/winapp-npm/src/jsbindings/codegen-runner.ts @@ -1,20 +1,9 @@ // Copyright (c) Microsoft Corporation and Contributors. All rights reserved. // Licensed under the MIT License. // -// Spawns @microsoft/dynwinrt-codegen against discovered .winmd metadata, -// using a stage-then-swap pattern so a partial failure leaves the previous -// output intact. -// -// Ported from C# `DynWinrtCodegenService.cs`. Key invariants preserved: -// * Resolve output dir with strict workspace containment + reparse-point -// refusal — the directory is wiped before each run, so we must never -// follow a junction that points outside the workspace. -// * Refuse to wipe a non-empty output directory without our managed marker -// (`.dynwinrt-managed`); the user may have aimed the path at real files. -// * Stage in a sibling dir, then atomic-rename swap with backup/restore on -// failure so a kill mid-rename can't leave the user with no bindings. -// * Use ArgumentList-equivalent (spawn args array) to avoid shell quoting -// pitfalls — paths with spaces or `&` must pass through unchanged. +// Stage-then-swap keeps previous bindings intact on codegen failure. +// Output directories are wiped, so workspace containment and reparse checks are security-critical. +// spawn receives an args array (not a shell string) so paths with spaces or `&` survive unchanged. import * as fs from 'fs'; import * as path from 'path'; @@ -24,8 +13,7 @@ import { spawn } from 'child_process'; import { JsBindingsConfig } from './package-json-config'; import { assertSafeWorkspaceOutputDir, isNetworkPath, hasReparsePointOnPath } from './path-safety'; -// Marker written into the output dir after a successful run; its presence -// authorises the next run to wipe the dir. +// Authorises later runs to wipe the generated output dir. export const MANAGED_MARKER_FILE_NAME = '.dynwinrt-managed'; const CODEGEN_PACKAGE_NAME = '@microsoft/dynwinrt-codegen'; @@ -38,19 +26,16 @@ export interface CodegenCherryPick { export interface CodegenInputs { config: JsBindingsConfig; - /** Emit winmds (after winmd-policy filtering + bulk additionalWinmds entries). */ + /** Winmds that generate bindings after policy filtering. */ emitWinmds: readonly string[]; - /** Ref-only winmds (load for type resolution, don't generate bindings). */ + /** Winmds loaded for type resolution only. */ refWinmds: readonly string[]; - /** Cherry-pick passes — each runs codegen once with `--namespace` + `--class-name` filters. */ + /** Per-class generation passes from cherry-picked additionalWinmds. */ cherryPicks: readonly CodegenCherryPick[]; workspaceDir: string; - /** A logger sink for stdout/stderr lines from the codegen child. */ + /** Sink for stdout/stderr lines from the codegen child. */ log?: (line: string) => void; - /** - * When false (default), child stdout is buffered and only printed on failure; - * stderr is always forwarded. When true, stream stdout/stderr line-by-line. - */ + /** false buffers stdout until failure; true streams child output. */ verbose?: boolean; } @@ -62,7 +47,7 @@ export interface CodegenSummary { export interface CodegenResult { outputDir: string; - /** Aggregated counts parsed from codegen stdout. Zeros if not detected. */ + /** Aggregated counts parsed from codegen stdout. */ summary: CodegenSummary; } @@ -75,7 +60,7 @@ export async function runCodegen(inputs: CodegenInputs): Promise fs.mkdirSync(path.dirname(outputDir), { recursive: true }); const emit = dedupeCaseInsensitive(inputs.emitWinmds); - // Drop refs that are already in emit (file in both wins as emit). + // File in both sets wins as emit. const refSet = new Set(emit.map((f) => f.toLowerCase())); const refs = dedupeCaseInsensitive(inputs.refWinmds.filter((r) => !refSet.has(r.toLowerCase()))); @@ -106,13 +91,8 @@ export async function runCodegen(inputs: CodegenInputs): Promise return { outputDir, summary }; } -// ---- output dir resolution + safety --------------------------------------- - export function resolveOutputDir(workspaceDir: string, output: string): string { - // Single source of truth for "this directory will be wiped before each - // codegen run" safety policy: must be UNC-free, strictly inside the - // workspace, and reparse-point-free along the entire path. Mirrors - // PathSafety guards on the native side. + // This directory is wiped each run; keep it inside the workspace and reparse-free. const out = output && output.trim() ? output : 'bindings'; return assertSafeWorkspaceOutputDir(workspaceDir, out, 'jsBindings.output'); } @@ -171,8 +151,6 @@ function writeManagedMarker(outputDir: string): void { fs.writeFileSync(markerPath, lines.join('\n'), { encoding: 'utf8' }); } -// ---- staging + swap -------------------------------------------------------- - /** Stage → backup-old → swap → drop-backup. Visible for tests. */ export async function runWithStaging( outputDir: string, @@ -201,8 +179,7 @@ export async function runWithStaging( try { fs.renameSync(stagingDir, outputDir); - // Don't let the finally block target the now-renamed staging dir - // (which IS the user's new output). + // Don't let finally delete the now-renamed user output. stagingActive = false; } catch (swapErr) { // Restore previous output so the user isn't left empty. @@ -211,8 +188,7 @@ export async function runWithStaging( fs.renameSync(backupDir, outputDir); backupDir = null; } catch (restoreErr) { - // Preserve the backup on disk and surface the path so the user - // can recover manually. Null the local so finally won't delete it. + // Keep the backup so the user can recover manually. const preserved = backupDir; backupDir = null; throw new Error( @@ -243,8 +219,6 @@ export async function runWithStaging( } } -// ---- argv builders --------------------------------------------------------- - export function buildBulkArgs( prefixArgs: readonly string[], emitWinmds: readonly string[], @@ -294,8 +268,6 @@ export function buildExtraTypeArgs( return args; } -// ---- spawn ----------------------------------------------------------------- - async function spawnCodegen( executable: string, args: readonly string[], @@ -324,7 +296,6 @@ async function spawnCodegen( const stdout = Buffer.concat(stdoutChunks).toString('utf8').trimEnd(); const stderr = Buffer.concat(stderrChunks).toString('utf8').trimEnd(); if (code !== 0) { - // On failure, always surface both streams so the user can diagnose. if (stdout) { log(stdout); } @@ -334,11 +305,7 @@ async function spawnCodegen( reject(new Error(`dynwinrt-codegen failed (exit ${code ?? 'null'}). See output above for details.`)); return; } - // Success: in quiet mode, swallow both streams. dynwinrt-codegen emits - // per-file "Generated …" lines plus a "Discovered N namespace(s)" dump - // (some via stderr as progress) that drown out the orchestrator's own - // single-line success summary. Users who need the detail can pass - // `--verbose` / `-v` (handleInit / handleRestore in cli.ts). + // Quiet success suppresses codegen's noisy per-file progress; use --verbose for details. if (verbose) { if (stdout) { log(stdout); @@ -352,8 +319,6 @@ async function spawnCodegen( }); } -// ---- summary parsing ------------------------------------------------------- - const SUMMARY_REGEX = /Done\.\s+(\d+)\s+class\(es\)\s+\+\s+(\d+)\s+interface\(s\)\s+\+\s+(\d+)\s+enum\(s\)\s+generated/i; @@ -363,9 +328,7 @@ export function parseSummary(stdout: string): CodegenSummary { if (!stdout) { return summary; } - // Codegen may emit one summary per pass; take the last one in case of - // multi-pass output reaching this function (defensive — currently each - // spawn is its own pass). + // Take the last summary if a multi-pass output reaches this function. const re = new RegExp(SUMMARY_REGEX, 'gi'); let last: RegExpExecArray | null = null; for (let match = re.exec(stdout); match !== null; match = re.exec(stdout)) { @@ -385,27 +348,14 @@ function accumulateSummary(target: CodegenSummary, add: CodegenSummary): void { target.enums += add.enums; } -// ---- executable resolution ------------------------------------------------- - interface CodegenInvocation { executable: string; prefixArgs: string[]; } -// Locate dynwinrt-codegen. Preferred resolution order: -// 1. `require.resolve('@microsoft/dynwinrt-codegen/package.json')` anchored -// at the wrapper directory — this is the canonical Node module-resolver, -// so it works with hoisted node_modules (npm / yarn-classic), -// pnpm-default's symlinked layout, and yarn-Berry PnP. -// 2. Physical node_modules walk — defensive fallback for the rare case -// where the wrapper is loaded via something that breaks -// `require.resolve` (e.g., custom bundler with frozen paths). -// -// Workspace-local installs are still preferred (a wrapper installed under -// the user's workspace co-locates the codegen there), and we only trust -// `cli.js` at a real on-disk path that we can lstat — so PnP's virtual -// `.zip!/` paths are converted to an unzipped on-disk location by Node -// itself before we read them. +// Preferred resolution order: +// 1. Node module resolution from the wrapper directory for npm, pnpm, yarn classic, and PnP. +// 2. Physical node_modules walking for bundled or patched layouts where require.resolve is stubbed. export function resolveCodegenInvocation(): CodegenInvocation { const wrapperDir = tryGetWrapperDir(); const arch = resolveArchSubdir(); @@ -413,18 +363,13 @@ export function resolveCodegenInvocation(): CodegenInvocation { const pkgDirs = resolveCodegenPackageDirs(wrapperDir); let lastChecked: string | null = null; for (const pkgDir of pkgDirs) { - // Priority 1: pre-built .exe (no Node startup needed). + // Prefer the pre-built .exe; cli.js is a defensive fallback. const exePath = path.join(pkgDir, 'bin', arch, 'dynwinrt-codegen.exe'); if (fs.existsSync(exePath)) { return { executable: exePath, prefixArgs: [] }; } - // Priority 2: cli.js via node — defensive fallback. Prefer the current - // wrapper's own interpreter (`process.execPath`) over PATH lookup so a - // poisoned PATH (UNC entry, reparse junction, attacker-controlled dir) - // can't substitute a hostile node.exe for cli.js execution. We still - // walk PATH as a last resort for the unusual case where the wrapper is - // launched from a non-node interpreter (e.g. an `.exe` shim). + // Prefer process.execPath for cli.js so a poisoned PATH cannot substitute node.exe. const cliJs = path.join(pkgDir, 'cli.js'); if (fs.existsSync(cliJs)) { const nodePath = resolveTrustedNodeInterpreter(); @@ -461,16 +406,8 @@ export function resolveCodegenInvocation(): CodegenInvocation { } /** - * Build the list of candidate `@microsoft/dynwinrt-codegen` package - * directories. Iterates lazily so we stop as soon as the first match has - * been fully validated by the caller. - * - * Order: - * * Anchored `require.resolve` from the wrapper dir. Honors all linkers - * (hoisted, isolated, PnP) because it goes through Node's own resolver. - * * Physical `node_modules/@microsoft/dynwinrt-codegen` walk from the - * wrapper dir upward — same as the legacy behaviour, kept as a safety - * net for bundled / patched layouts where `require.resolve` is stubbed. + * Yield package dirs via Node resolution, then physical node_modules fallback. + * The iterator stops once the caller fully validates the first usable package. */ function* resolveCodegenPackageDirs(wrapperDir: string | null): Generator { const seen = new Set(); @@ -483,10 +420,10 @@ function* resolveCodegenPackageDirs(wrapperDir: string | null): Generator already exists. Overwrite?` prompts. Default Yes matches - // those native prompts; users can answer N to preserve customizations. + // Init re-runs mirror native overwrite prompts; default Yes preserves UX parity. if (inputs.existingJsBindings) { const isDotNet = detectDotNetProject(inputs.workspaceDir); if (isDotNet) { - // Edge: someone added winapp.jsBindings to a .NET project and is now - // re-running init. Honor .NET classification and silent-preserve. return { kind: 'yes', silentReason: '.NET project detected — preserving existing winapp.jsBindings without prompting.', @@ -85,8 +58,7 @@ export async function askBindingsKind(inputs: BindingsPromptInputs): Promise` or `--setup-sdks=` in the argv. -// Mirrors the native option exactly; we don't validate the value beyond the -// "none" check (native will reject invalid values). -// -// Exported so cli.ts can fast-path `init --setup-sdks none` straight to the -// native CLI without invoking the bindings prompt (parity with the -// pre-wrapper UX where --setup-sdks none was a no-op for JS bindings). +// cli.ts uses this to fast-path `init --setup-sdks none`; native validates values. export function parseSetupSdksArg(argv: readonly string[]): string | undefined { for (let i = 0; i < argv.length; i++) { const a = argv[i]; @@ -169,19 +133,10 @@ export function parseSetupSdksArg(argv: readonly string[]): string | undefined { return undefined; } -/** - * Mirrors Spectre.Console's `ConfirmationPrompt` rendering used by the native - * CLI: live prompt shows `{title} [y/n] (y):` with the hint in dim grey, - * and after the user answers the line is rewritten as `{title}: ` - * with the answer underlined. Keeps init UX consistent across native and - * npm-wrapper prompts. - */ +/** Mirrors the native CLI confirmation prompt UX. */ async function confirmationPrompt(title: string, defaultYes: boolean = true): Promise { const useColor = !!process.stdout.isTTY && !process.env.NO_COLOR; - // Match Spectre.Console's default ConfirmationPrompt palette: - // * Choices `[y/n]` → blue (ChoicesStyle default) - // * Default value `(y)` → green (DefaultValueStyle default) - // * Post-answer value → underline (matches our C# rewrite path) + // Match Spectre.Console's ConfirmationPrompt palette for native/npm parity. const blue = (s: string) => (useColor ? `\x1b[34m${s}\x1b[39m` : s); const green = (s: string) => (useColor ? `\x1b[32m${s}\x1b[39m` : s); const underline = (s: string) => (useColor ? `\x1b[4m${s}\x1b[24m` : s); @@ -192,8 +147,7 @@ async function confirmationPrompt(title: string, defaultYes: boolean = true): Pr const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); try { - // Loop until we get a recognized answer (or an empty answer, which uses - // the default). Matches Spectre's behavior of refusing garbage input. + // Keep retrying to match Spectre's refusal of unrecognized answers. for (;;) { const raw = await question(rl, livePrompt); const trimmed = (raw ?? '').trim().toLowerCase(); @@ -208,13 +162,11 @@ async function confirmationPrompt(title: string, defaultYes: boolean = true): Pr } if (result === null) { - // Invalid — re-prompt (Spectre prints validation error; we keep it terse). continue; } if (useColor) { - // Move cursor up one line (over the line we just wrote), clear it, - // then rewrite the prompt with the underlined answer. + // Rewrite the live prompt with the underlined answer. process.stdout.write('\x1b[1A\x1b[2K\r'); process.stdout.write(`${title}: ${underline(result ? 'Yes' : 'No')}\n`); } diff --git a/src/winapp-npm/src/jsbindings/lockfile-reader.ts b/src/winapp-npm/src/jsbindings/lockfile-reader.ts index 3ab20b21..7ef02667 100644 --- a/src/winapp-npm/src/jsbindings/lockfile-reader.ts +++ b/src/winapp-npm/src/jsbindings/lockfile-reader.ts @@ -1,21 +1,13 @@ // Copyright (c) Microsoft Corporation and Contributors. All rights reserved. // Licensed under the MIT License. // -// Reads `.winapp/winmds.lock.json` written by the native CLI's `restore`. -// The lockfile is a pure NuGet winmd inventory keyed by package id; the -// emit/refOnly/skip classification lives in `winmd-policy.ts` and is -// applied at codegen time, not at lockfile time. -// -// Ported from C# `WinmdsLockfileService.TryReadAsync`. Schema version -// mismatches return null with a console hint so the caller can ask the -// user to re-run `winapp restore`. +// Reads the native restore lockfile; emit/refOnly/skip policy stays in winmd-policy.ts. import * as fs from 'fs'; import * as path from 'path'; import { assertSafeWorkspaceFile, isNetworkPath, hasReparsePointOnPath } from './path-safety'; -// Schema bumped to 3 when the npm wrapper took over JS bindings: schema 2 -// embedded a `category` field that is now strictly an npm-side computation. +// Schema 3 dropped native-side JS binding categories; npm computes them now. export const LOCKFILE_SCHEMA_VERSION = 3; export const LOCKFILE_NAME = 'winmds.lock.json'; @@ -33,36 +25,26 @@ export interface WinmdsLockfile { packages: WinmdsLockfilePackage[]; } -/** - * Lockfile lives at `/.winapp/winmds.lock.json`. Exposed so - * `cli.ts` can `existsSync`-probe without re-reading or parsing the file. - */ +/** Exposed so cli.ts can probe without parsing the lockfile. */ export function getLockfilePath(workspaceDir: string): string { return path.join(workspaceDir, '.winapp', LOCKFILE_NAME); } export interface ReadLockfileResult { lockfile: WinmdsLockfile | null; - /** Human-readable reason when lockfile is null but a file existed. */ + /** Human-readable reason when null and a file existed or was unsafe. */ reason?: string; } /** - * Read + parse the workspace's lockfile, validating schema version and the - * containment of every `winmds[]` path entry against the recorded - * `nuget_cache_dir`. Returns null when the file is missing, unreadable, - * malformed, or schema-mismatched — callers should treat any null as "trigger - * live discovery / ask the user to rerun restore". - * - * The path-safety guard is wired in so a hostile `.winapp/` (e.g. a junction - * pointing at another user's profile) is refused before we even open the - * lockfile. Matches the native side's `IsLockfilePathUnsafe()`. + * Read and validate the workspace lockfile. + * Hostile `.winapp` junctions are rejected before opening the file. */ export function tryReadLockfile(workspaceDir: string): ReadLockfileResult { const winappDir = path.join(workspaceDir, '.winapp'); const filePath = getLockfilePath(workspaceDir); - // Refuse to follow reparse points / UNC ancestors BEFORE probing existence. + // Refuse reparse/UNC ancestors before probing existence. try { assertSafeWorkspaceFile(workspaceDir, filePath, LOCKFILE_NAME); } catch (err) { @@ -101,9 +83,7 @@ export function tryReadLockfile(workspaceDir: string): ReadLockfileResult { } const obj = parsed as Record; - // Native writes JsonKnownNamingPolicy.SnakeCaseLower (see WinmdsLockfile.cs), - // so the on-disk keys are snake_case. We tolerate camelCase as a legacy/test - // fallback for any older lockfiles or hand-authored test fixtures. + // Native writes snake_case; camelCase remains a legacy/test fallback. const schemaRaw = obj.schema ?? obj.schemaVersion; const schemaVersion = typeof schemaRaw === 'number' ? schemaRaw : typeof schemaRaw === 'string' ? Number(schemaRaw) : Number.NaN; @@ -144,11 +124,8 @@ export function tryReadLockfile(workspaceDir: string): ReadLockfileResult { ? obj.yamlPackagesHash : undefined; - // Each winmd path must be a real file under the recorded NuGet cache — - // anything else (UNC, reparse-backed, or escaped via `..`) gets dropped - // with a logged reason. Without `nuget_cache_dir` we have no boundary, - // so we only enforce the UNC/empty checks and rely on the codegen to - // surface absolute-path requirements. + // Require lockfile winmds to stay under nuget_cache_dir; UNC/reparse escapes are dropped. + // Without nuget_cache_dir there is no boundary, so codegen surfaces absolute-path issues. const cacheBoundary = nugetCacheDir ? path.resolve(nugetCacheDir).replace(/[\\/]+$/, '') : null; const droppedPaths: string[] = []; const packages: WinmdsLockfilePackage[] = []; @@ -188,8 +165,7 @@ export function tryReadLockfile(workspaceDir: string): ReadLockfileResult { } if (droppedPaths.length > 0) { - // Surface the count + first few paths so a corrupted / tampered lockfile - // produces an actionable signal rather than silently emitting nothing. + // Include examples so tampered lockfiles are actionable. const head = droppedPaths.slice(0, 3).join(', '); const suffix = droppedPaths.length > 3 ? ` (+${droppedPaths.length - 3} more)` : ''; return { @@ -201,9 +177,7 @@ export function tryReadLockfile(workspaceDir: string): ReadLockfileResult { }; } - // Verify .winapp/ itself wasn't swapped between the existsSync probe and - // returning — best-effort secondary check; if this fails we report the same - // safety reason rather than a half-loaded lockfile. + // Best-effort check that .winapp wasn't swapped after the initial probe. try { assertSafeWorkspaceFile(workspaceDir, winappDir, '.winapp'); } catch (err) { diff --git a/src/winapp-npm/src/jsbindings/orchestrator.ts b/src/winapp-npm/src/jsbindings/orchestrator.ts index cae12564..8c909834 100644 --- a/src/winapp-npm/src/jsbindings/orchestrator.ts +++ b/src/winapp-npm/src/jsbindings/orchestrator.ts @@ -1,20 +1,8 @@ // Copyright (c) Microsoft Corporation and Contributors. All rights reserved. // Licensed under the MIT License. // -// Top-level glue for JS bindings generation. Called after native `winapp restore` -// has written the winmd lockfile and after we've established that the user -// wants JS bindings (`winapp.jsBindings` namespace present in package.json). -// -// Pipeline: -// 1. Read package.json → get jsBindings config (npm wrapper-owned). -// 2. Read .winapp/winmds.lock.json → get NuGet winmd inventory. -// 3. Resolve user-supplied additional winmds + refs (reparse / UNC safety). -// 4. Partition by package category (skip / refOnly / emit) with user overrides. -// 5. Run dynwinrt-codegen (bulk + per-extraType passes) into staged dir. -// 6. Ensure @microsoft/dynwinrt is in package.json dependencies + print PM hint. -// -// Returns a structured outcome (not exceptions for "no jsBindings configured") -// so the cli.ts caller can decide whether to print anything. +// Runs after native restore writes the lockfile and package.json opts into JS bindings. +// Returns structured outcomes so callers can decide what to print. import * as path from 'path'; import { readJsBindingsConfig } from './package-json-config'; @@ -35,31 +23,21 @@ export interface OrchestratorResult { outcome: OrchestratorOutcome; /** Human-readable diagnostic. Always set. */ message: string; - /** Output dir written by codegen (only when outcome === 'completed'). */ + /** Output dir written by codegen when completed. */ outputDir?: string; } export interface OrchestratorOptions { workspaceDir: string; - /** - * Explicit `winapp.yaml` path the native CLI used (resolved from `--config-dir` - * by the caller via {@link resolveYamlPath}). Defaults to - * `/winapp.yaml` for backward-compat; pass it explicitly - * whenever the user supplied `--config-dir` so the staleness check - * compares against the same file native hashed into the lockfile. - */ + /** Path native CLI hashed; keeps stale-lockfile checks aligned with --config-dir. */ yamlPath?: string; - /** Override for the npm wrapper's pinned dynwinrt version (used in tests). */ + /** Override for the npm wrapper's pinned dynwinrt version, used in tests. */ versionOverride?: string; - /** Sink for per-line progress (stdout/stderr from codegen). Defaults to console. */ + /** Sink for codegen progress lines; defaults to console. */ log?: (line: string) => void; - /** Forward to codegen-runner. False (default) suppresses per-file noise. */ + /** Forward to codegen-runner; false suppresses per-file noise. */ verbose?: boolean; - /** - * Suppress all non-essential progress / hint output. Errors and warnings - * still go through `log`; the spinner, `🔨` fallback, and runtime-dep hint - * are skipped. Used by `--quiet` on the wrapper. - */ + /** Suppress progress and hints; warnings still go through `log`. */ quiet?: boolean; } @@ -67,7 +45,6 @@ export async function runJsBindingsPipeline(options: OrchestratorOptions): Promi const log = options.log ?? ((line) => console.log(line)); const workspaceDir = path.resolve(options.workspaceDir); - // 1. Read package.json for the `winapp.jsBindings` namespace. const pkgResult = readJsBindingsConfig(workspaceDir); if (!pkgResult.packageJsonExists) { return { @@ -83,7 +60,6 @@ export async function runJsBindingsPipeline(options: OrchestratorOptions): Promi } const config = pkgResult.jsBindings; - // 2. Read lockfile (workspace-scoped: lives at /.winapp/winmds.lock.json). const lockResult = tryReadLockfile(workspaceDir); if (!lockResult.lockfile) { return { @@ -98,12 +74,7 @@ export async function runJsBindingsPipeline(options: OrchestratorOptions): Promi } const lockfile = lockResult.lockfile; - // 2a. Compare the lockfile's recorded `yaml_packages_hash` against a fresh - // hash of `winapp.yaml`. If the user edited the SDK pins without - // re-running `winapp restore`, the lockfile's winmd inventory is for - // the OLD packages — emitting JS bindings now would generate against - // stale types. Surface as `lockfileStale` so the cli.ts caller prints - // the actionable `winapp restore` hint. + // If SDK pins changed after restore, codegen would emit against stale winmd inventory. if (lockfile.yamlPackagesHash) { const currentPackages = readWinappYamlPackages(workspaceDir, options.yamlPath); if (currentPackages) { @@ -120,9 +91,7 @@ export async function runJsBindingsPipeline(options: OrchestratorOptions): Promi } } - // 3. Resolve user-supplied additional winmds + refs (path safety + dedupe). - // `additionalWinmds` entries can be bulk (winmdPath only) or cherry-pick - // (winmdPath + namespace + classes); we split them after resolution. + // User-supplied winmd paths go through UNC/reparse safety checks before codegen sees them. const userEmit = resolveAdditionalWinmds(config.additionalWinmds, workspaceDir, 'additionalWinmds'); const userRefs = resolveAdditionalWinmds( (config.additionalRefs ?? []).map((p) => ({ winmdPath: p })), @@ -133,9 +102,7 @@ export async function runJsBindingsPipeline(options: OrchestratorOptions): Promi log(w); } - // Split resolved additionalWinmds into bulk emit vs cherry-pick passes. - // Cherry-pick entries are loaded as ref-only so codegen can resolve types; - // only the listed classes are emitted. + // Cherry-pick entries load as refs; only listed classes are emitted. const bulkAdditional: string[] = []; const cherryPicks: { namespace: string; classes: string[] }[] = []; const cherryPickRefs: string[] = []; @@ -148,11 +115,11 @@ export async function runJsBindingsPipeline(options: OrchestratorOptions): Promi } } - // 4. Partition NuGet winmds by built-in package category (no user overrides). + // Built-in NuGet policy has no user overrides; additionalWinmds are explicit overrides. const partition = partitionPackageWinmds(lockfile.packages); - // 5. Compose final emit + ref sets. const emitWinmds = [...partition.emit, ...bulkAdditional]; + // Cherry-pick winmds are refs because only their requested classes are emitted. const refWinmds = [...partition.refOnly, ...userRefs.resolved.map((r) => r.winmdPath), ...cherryPickRefs]; if (emitWinmds.length === 0 && cherryPicks.length === 0) { @@ -164,11 +131,8 @@ export async function runJsBindingsPipeline(options: OrchestratorOptions): Promi }; } - // 6. Run codegen. Show a TTY spinner so the user sees progress during the - // ~30s where codegen-runner suppresses all child output (quiet mode). - // Spinner is suppressed in verbose mode (where codegen prints its own - // line-by-line output) and when the caller injected a custom log sink - // (e.g., tests — we mustn't interleave ANSI noise with assertion output). + // Spinner covers quiet child output; skip it for verbose output or injected log sinks. + // Tests inject logs, so avoid interleaving ANSI spinner output with assertions. const progressText = `Generating JS bindings from ${emitWinmds.length} winmd${emitWinmds.length === 1 ? '' : 's'}` + (refWinmds.length > 0 ? ` (+${refWinmds.length} ref)` : '') + @@ -196,7 +160,7 @@ export async function runJsBindingsPipeline(options: OrchestratorOptions): Promi spinner?.stop(); } - // 7. Ensure runtime dep + print PM hint. + // Runtime dep injection is best-effort; codegen output is still useful if it fails. const pinnedVersion = options.versionOverride ?? safeGetVersionPin(log); if (pinnedVersion) { try { @@ -212,7 +176,7 @@ export async function runJsBindingsPipeline(options: OrchestratorOptions): Promi log(hint.message); } } catch (err) { - // Warnings always surface, even in --quiet, so users still see real failures. + // Warnings always surface, even in --quiet. log(`⚠️ Failed to ensure runtime dependency: ${(err as Error).message}`); } } diff --git a/src/winapp-npm/src/jsbindings/package-json-config.ts b/src/winapp-npm/src/jsbindings/package-json-config.ts index 786628fd..a9744ef3 100644 --- a/src/winapp-npm/src/jsbindings/package-json-config.ts +++ b/src/winapp-npm/src/jsbindings/package-json-config.ts @@ -1,20 +1,5 @@ // Copyright (c) Microsoft Corporation and Contributors. All rights reserved. // Licensed under the MIT License. -// -// Reads and writes the `"winapp": { "jsBindings": {...} }` namespace inside -// the workspace's package.json. -// -// Why package.json instead of winapp.yaml? -// * `winapp.yaml` is owned by the native CLI and only describes SDK -// `packages:` pins. Layering JS-only configuration in there meant the -// native CLI had to either parse and ignore a JS-only block or risk -// mangling unknown keys on round-trip. -// * package.json already exists in every npm/Node workspace and is the -// canonical place for Node-tool configuration (eslint, jest, prettier, -// tsup, ...). The `"winapp"` key follows the same convention. -// * The native CLI now has zero awareness of JS bindings — every code path -// (init, restore, package, ...) is identical regardless of whether the -// user opted into JS bindings. import { AdditionalWinmd } from './additional-winmds'; import { readPackageJsonDoc, mutatePackageJsonDoc, packageJsonExists } from './package-json-doc'; @@ -22,10 +7,9 @@ import { readPackageJsonDoc, mutatePackageJsonDoc, packageJsonExists } from './p export interface JsBindingsConfig { // Output directory, relative to the workspace root. output: string; - // Extra .winmd files to feed into the codegen. Each entry either bulk-emits - // the whole winmd or cherry-picks individual classes from it. + // Extra .winmd files to feed into codegen, either bulk-emitted or cherry-picked. additionalWinmds: AdditionalWinmd[]; - // Extra .winmd files loaded for type resolution only (no emit). + // Extra .winmd files loaded for type resolution only. additionalRefs: string[]; } @@ -40,19 +24,11 @@ export function defaultJsBindingsConfig(): JsBindingsConfig { export interface ReadJsBindingsResult { /** True when package.json existed and parsed successfully. */ packageJsonExists: boolean; - /** Parsed jsBindings config, or null when the namespace isn't present. */ + /** Parsed config, or null when `winapp.jsBindings` isn't present. */ jsBindings: JsBindingsConfig | null; } -/** - * Read package.json from the workspace and return any - * `"winapp": { "jsBindings": {...} }` namespace it declares. - * - * Missing file (or unsafe workspace path) → `{ packageJsonExists: false, jsBindings: null }`. - * Present file, no `winapp.jsBindings` → `{ packageJsonExists: true, jsBindings: null }`. - * Malformed JSON propagates as an exception so callers can surface a clear - * error rather than silently treating the workspace as un-configured. - */ +/** Read package.json and return `winapp.jsBindings` when present. */ export function readJsBindingsConfig(workspaceDir: string): ReadJsBindingsResult { const doc = readPackageJsonDoc(workspaceDir); if (!doc) { @@ -67,51 +43,21 @@ export function readJsBindingsConfig(workspaceDir: string): ReadJsBindingsResult return { packageJsonExists: true, jsBindings: coerceConfig(block) }; } -/** - * Convenience: returns true when package.json declares `winapp.jsBindings`. - * Propagates JSON parse errors (does NOT swallow them) — a malformed - * package.json should fail the command with the actual parse error rather - * than silently skip codegen. Callers should `try` around this if they need - * to handle malformed input gracefully. - */ +/** Propagates malformed package.json errors instead of silently skipping codegen. */ export function hasJsBindings(workspaceDir: string): boolean { return readJsBindingsConfig(workspaceDir).jsBindings !== null; } -/** - * Outcome of {@link ensureJsBindingsBlock}. - * * `added` — namespace was missing; default block written. - * * `reset` — namespace existed but caller asked to overwrite it with defaults. - * * `unchanged` — namespace existed and caller did not request a reset. - */ export type EnsureJsBindingsOutcome = 'added' | 'reset' | 'unchanged'; export interface EnsureJsBindingsOptions { - /** - * When true, overwrite an existing `winapp.jsBindings` block with the - * default config. Use this when the user explicitly opted in again - * (e.g. re-running `winapp init` and answering Yes after previously - * customizing the block) — we never silently overwrite otherwise. - */ + /** Only reset existing user config after explicit opt-in. */ reset?: boolean; /** Suppress the informational banner printed to stdout. */ quiet?: boolean; } -/** - * Make sure the workspace's package.json declares the - * `winapp.jsBindings` namespace, then return what we did. - * - * Shared by `winapp init` (after a "yes" answer) and - * `winapp node generate-bindings` (so the command works without making - * the user hand-edit JSON before invoking it). NOT called from - * `winapp restore` — restore must remain a passive "respect existing - * declarations" operation and never silently add config the user did - * not request. - * - * Requires package.json to exist; callers should fail with a clear - * "this is not an npm project" error first when it does not. - */ +/** Ensure package.json declares `winapp.jsBindings`; explicit opt-in only, never restore. */ export function ensureJsBindingsBlock( workspaceDir: string, opts: EnsureJsBindingsOptions = {} @@ -137,23 +83,8 @@ export function ensureJsBindingsBlock( } /** - * Write (or update) the `"winapp": { "jsBindings": {...} }` namespace in - * package.json. - * - * Behaviour: - * * Preserves the existing 2-space indent + trailing newline (via - * `mutatePackageJsonDoc`). We do not pull in `prettier` for this single - * edit — JSON.stringify gives us a stable canonical layout and - * `package.json` is the only file we own. - * * Atomic: writes to a sibling temp file, fsyncs, then renames over the - * real file so a half-written package.json is never visible. - * * Inserts the `"winapp"` key at the end of the top-level object when it - * does not yet exist — npm tooling does not care about key order, and - * stable insertion keeps round-trips clean. - * * Throws when package.json is missing or malformed; callers should - * ensure the file exists (e.g. by suggesting `npm init -y`) before - * writing. - * * Throws when the workspace path is UNC or has a reparse-point ancestor. + * Write or update the `winapp.jsBindings` namespace in package.json. + * `mutatePackageJsonDoc` preserves JSON layout and performs the atomic write. */ export function writeJsBindingsConfig(workspaceDir: string, config: JsBindingsConfig): void { if (!packageJsonExists(workspaceDir)) { @@ -172,20 +103,11 @@ export function writeJsBindingsConfig(workspaceDir: string, config: JsBindingsCo }); } -/** - * Hook for tests / future helpers: render the config block as it would be - * embedded in package.json. Returns the JSON-serializable shape — callers - * typically don't need this directly, but the orchestrator tests use it to - * assert round-trip behaviour without re-implementing the schema. - */ +/** Render the JSON-serializable config shape embedded in package.json. */ export function renderJsBindingsConfig(config: JsBindingsConfig): unknown { return serializeConfig(config); } -// --------------------------------------------------------------------------- -// Internals -// --------------------------------------------------------------------------- - function coerceConfig(raw: unknown): JsBindingsConfig { const defaults = defaultJsBindingsConfig(); if (!raw || typeof raw !== 'object') { @@ -242,12 +164,7 @@ function coerceAdditionalWinmds(value: unknown): AdditionalWinmd[] { return out; } -/** - * Serialize a JsBindingsConfig in a stable, schema-faithful shape: - * * keys are emitted in a fixed order so diffs stay clean across edits; - * * empty arrays are kept (they're documentation: "yes I considered this, - * and meant the empty default") rather than stripped. - */ +/** Stable key order; empty arrays remain explicit defaults in package.json. */ function serializeConfig(config: JsBindingsConfig): Record { return { output: config.output, @@ -263,6 +180,4 @@ function serializeConfig(config: JsBindingsConfig): Record { }; } -// Re-exported so callers don't have to know whether the implementation lives -// in this module or elsewhere. export { PACKAGE_JSON_FILENAME } from './package-json-doc'; From 005a4d3b490c105a519546e4e302f34a3ea18419 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Sat, 23 May 2026 00:06:16 +0800 Subject: [PATCH 20/27] bug fix and new tests added --- .github/plugin/agents/winapp.agent.md | 2 +- docs/usage.md | 2 +- samples/electron/test.Tests.ps1 | 44 +++++++++++++++++++ .../YamlPackagesHasherTests.cs | 23 ++++++++++ .../src/jsbindings/additional-winmds.ts | 30 ++++++++----- .../src/jsbindings/codegen-runner.ts | 13 +++--- src/winapp-npm/src/jsbindings/init-prompt.ts | 2 +- src/winapp-npm/src/jsbindings/orchestrator.ts | 43 +++++++++++------- .../src/jsbindings/package-json-config.ts | 3 +- .../src/jsbindings/yaml-packages-hash.ts | 11 +++++ 10 files changed, 138 insertions(+), 35 deletions(-) diff --git a/.github/plugin/agents/winapp.agent.md b/.github/plugin/agents/winapp.agent.md index ada32c36..18b18f9d 100644 --- a/.github/plugin/agents/winapp.agent.md +++ b/.github/plugin/agents/winapp.agent.md @@ -228,7 +228,7 @@ Want to inspect or interact with a running app's UI? - Use `winapp node add-electron-debug-identity` / `clear-electron-debug-identity` for identity management - **⚠️ Always run `npx winapp node add-electron-debug-identity` before testing any Windows API that requires package identity** — without this, APIs will fail at runtime - Guide: https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/setup.md -- JS bindings reference: https://github.com/microsoft/WinAppCli/blob/main/docs/js-bindings.md +- JS bindings reference: https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/js-bindings.md ### .NET (WPF, WinForms, Console) - **Setup:** `winapp init --use-defaults` — but if you already have a `Package.appxmanifest` (e.g., WinUI 3 apps), you likely **don't need `winapp init`**. Just ensure your `.csproj` references the `Microsoft.WindowsAppSDK` NuGet package and has the right properties for packaged builds. diff --git a/docs/usage.md b/docs/usage.md index f9bff3b6..d72cfa3c 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -168,7 +168,7 @@ Each generation auto-injects `@microsoft/dynwinrt` as a production dependency in Bindings are **npm-only** — they require invocation via `npx winapp` (the `@microsoft/winappcli` npm package); the standalone winget CLI does not surface them. -> See [JS bindings guide](guides/electron/js-bindings.md) for the full `winapp.jsBindings` schema, per-package winmd categorization (skip / refOnly / emit overrides), and the `winmds.lock.json` audit artifact. +> See [JS bindings guide](guides/electron/js-bindings.md) for the full `winapp.jsBindings` schema, the built-in per-package winmd categorization (skip / refOnly / emit), and the `winmds.lock.json` audit artifact. --- diff --git a/samples/electron/test.Tests.ps1 b/samples/electron/test.Tests.ps1 index cbf01711..77bd58e5 100644 --- a/samples/electron/test.Tests.ps1 +++ b/samples/electron/test.Tests.ps1 @@ -170,6 +170,50 @@ Describe "Electron Sample" { -Because "restore should leave the managed marker in place after regen" } + It "Should regenerate bindings via 'winapp node generate-bindings' (codegen-only path)" -Skip:$script:skip { + $bindingsDir = Join-Path $script:appDir "bindings" + # Wipe bindings/ to prove generate-bindings re-creates from the cached lockfile. + if (Test-Path $bindingsDir) { + Remove-Item -Recurse -Force $bindingsDir + } + Push-Location $script:appDir + try { + Invoke-WinappCommand -Arguments "node generate-bindings" + } finally { Pop-Location } + (Join-Path $bindingsDir ".dynwinrt-managed") | Should -Exist ` + -Because "generate-bindings must re-emit the managed marker" + (Join-Path $bindingsDir "index.js") | Should -Exist ` + -Because "generate-bindings must re-emit the bindings index" + } + + It "Should detect winapp.yaml drift and refuse generate-bindings (cross-language hash parity)" -Skip:$script:skip { + # End-to-end check that the TS yaml-packages-hash matches the C# + # YamlPackagesHasher used by `winapp restore`. If they drift, this + # test silently passes generate-bindings even though winmds are + # stale — exactly the regression the parity test guards against. + $yamlPath = Join-Path $script:appDir "winapp.yaml" + $original = Get-Content $yamlPath -Raw + try { + $modified = $original -replace '(?m)^(\s*version:\s*)["'']?([\d\.]+)["'']?', '${1}999.999.999' + $modified | Should -Not -Be $original -Because "must actually modify winapp.yaml for the test to be meaningful" + Set-Content -Path $yamlPath -Value $modified -NoNewline + Push-Location $script:appDir + try { + # generate-bindings exits non-zero with a stale-lockfile message; + # capture both streams. Invoke-WinappCommand throws on non-zero, + # so use the raw helper that captures output. + $exitCode = 0 + $output = & npx --no-install winapp node generate-bindings 2>&1 + $exitCode = $LASTEXITCODE + ($output -join "`n") | Should -Match "stale|drift|restore" ` + -Because "generate-bindings must surface the stale-lockfile reason" + $exitCode | Should -Not -Be 0 -Because "stale-lockfile path must exit non-zero" + } finally { Pop-Location } + } finally { + Set-Content -Path $yamlPath -Value $original -NoNewline + } + } + It "Should create a C++ native addon" -Skip:$script:skip { Push-Location $script:appDir try { diff --git a/src/winapp-CLI/WinApp.Cli.Tests/YamlPackagesHasherTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/YamlPackagesHasherTests.cs index a59c39e0..829c592a 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/YamlPackagesHasherTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/YamlPackagesHasherTests.cs @@ -76,4 +76,27 @@ public void Compute_SkipsBlankNames() var b = new[] { new PackagePin { Name = "Foo", Version = "1.0" } }; Assert.AreEqual(YamlPackagesHasher.Compute(a), YamlPackagesHasher.Compute(b)); } + + [TestMethod] + public void Compute_GoldenFixture_PinsHashForCrossLanguageParity() + { + // PINNED REFERENCE FIXTURE — the TS implementation in + // src/winapp-npm/src/jsbindings/yaml-packages-hash.ts must produce + // EXACTLY this hex for the same logical input. If the two drift, + // stale-lockfile detection silently breaks (TS sees a different + // hash than what restore wrote, but reports no change). + // + // When updating either side, recompute on both and update this hex + // together. Sample inputs taken from a realistic Electron workspace + // (lowercase normalization, ordinal sort by `lower(name)|version`). + var packages = new[] + { + new PackagePin { Name = "Microsoft.WindowsAppSDK", Version = "2.1.3" }, + new PackagePin { Name = "Microsoft.Windows.SDK.CPP", Version = "10.0.28000.1839" }, + }; + Assert.AreEqual( + "8581abfcb53fa04056a066fc7098c5d94064cc275e20f0e547365c1b8b146e54", + YamlPackagesHasher.Compute(packages), + "Hash drift detected — update yaml-packages-hash.ts to match (or vice-versa)."); + } } diff --git a/src/winapp-npm/src/jsbindings/additional-winmds.ts b/src/winapp-npm/src/jsbindings/additional-winmds.ts index faa1dec7..9be1dbc3 100644 --- a/src/winapp-npm/src/jsbindings/additional-winmds.ts +++ b/src/winapp-npm/src/jsbindings/additional-winmds.ts @@ -42,7 +42,7 @@ export function resolveAdditionalWinmds( return { resolved, warnings }; } - const seen = new Set(); + const seenIndex = new Map(); const workspaceFull = path.resolve(workspaceDir).replace(/[\\/]+$/, ''); for (const entry of entries) { @@ -79,21 +79,31 @@ export function resolveAdditionalWinmds( continue; } - const dedupeKey = fullPath.toLowerCase(); - if (seen.has(dedupeKey)) { - continue; - } - seen.add(dedupeKey); + const ns = typeof entry.namespace === 'string' ? entry.namespace.trim() : ''; + const classes = Array.isArray(entry.classes) + ? entry.classes.map((c) => (typeof c === 'string' ? c.trim() : '')).filter((c) => c.length > 0) + : []; if (!fs.existsSync(fullPath)) { warnings.push(`jsBindings.${fieldName} entry not found, skipping: ${trimmed} (resolved to ${fullPath})`); continue; } - const ns = typeof entry.namespace === 'string' ? entry.namespace.trim() : ''; - const classes = Array.isArray(entry.classes) - ? entry.classes.map((c) => (typeof c === 'string' ? c.trim() : '')).filter((c) => c.length > 0) - : []; + const dedupeKey = `${fullPath.toLowerCase()}|${ns}`; + const existingIdx = seenIndex.get(dedupeKey); + if (existingIdx !== undefined) { + const existing = resolved[existingIdx]; + if (ns && classes.length > 0) { + const merged = new Set(existing.classes ?? []); + for (const c of classes) { + merged.add(c); + } + existing.namespace = ns; + existing.classes = [...merged]; + } + continue; + } + seenIndex.set(dedupeKey, resolved.length); const out: ResolvedAdditionalWinmd = { winmdPath: fullPath }; if (ns && classes.length > 0) { diff --git a/src/winapp-npm/src/jsbindings/codegen-runner.ts b/src/winapp-npm/src/jsbindings/codegen-runner.ts index 445dabb5..eb540bcb 100644 --- a/src/winapp-npm/src/jsbindings/codegen-runner.ts +++ b/src/winapp-npm/src/jsbindings/codegen-runner.ts @@ -20,6 +20,7 @@ const CODEGEN_PACKAGE_NAME = '@microsoft/dynwinrt-codegen'; /** One cherry-pick pass derived from `additionalWinmds[i]` with namespace+classes. */ export interface CodegenCherryPick { + winmdPath: string; namespace: string; classes: readonly string[]; } @@ -249,9 +250,9 @@ export function buildExtraTypeArgs( extra: CodegenCherryPick ): string[] { const args: string[] = [...prefixArgs, 'generate']; - if (emitWinmds.length > 0) { - args.push('--winmd', emitWinmds.join(';')); - } + const emitSet = new Set(emitWinmds); + emitSet.add(extra.winmdPath); + args.push('--winmd', Array.from(emitSet).join(';')); args.push( '--namespace', extra.namespace, @@ -262,8 +263,9 @@ export function buildExtraTypeArgs( '--lang', 'js' ); - if (refWinmds.length > 0) { - args.push('--ref', refWinmds.join(';')); + const refs = refWinmds.filter((r) => r !== extra.winmdPath); + if (refs.length > 0) { + args.push('--ref', refs.join(';')); } return args; } @@ -446,6 +448,7 @@ function resolveViaRequireResolve(wrapperDir: string | null): string | null { return pkgDir; } } catch { + // require.resolve throws on no-match; treat as "not installed". } return null; } diff --git a/src/winapp-npm/src/jsbindings/init-prompt.ts b/src/winapp-npm/src/jsbindings/init-prompt.ts index 7b446385..6198a884 100644 --- a/src/winapp-npm/src/jsbindings/init-prompt.ts +++ b/src/winapp-npm/src/jsbindings/init-prompt.ts @@ -25,7 +25,7 @@ export interface BindingsPromptOutcome { overwriteExistingConfig?: boolean; } -const USE_DEFAULTS_FLAGS = new Set(['--use-defaults', '-y', '--yes']); +const USE_DEFAULTS_FLAGS = new Set(['--use-defaults', '--no-prompt', '-y', '--yes']); export async function askBindingsKind(inputs: BindingsPromptInputs): Promise { // Restore never re-prompts: respect whatever the workspace already declares. diff --git a/src/winapp-npm/src/jsbindings/orchestrator.ts b/src/winapp-npm/src/jsbindings/orchestrator.ts index 8c909834..3c040a37 100644 --- a/src/winapp-npm/src/jsbindings/orchestrator.ts +++ b/src/winapp-npm/src/jsbindings/orchestrator.ts @@ -77,17 +77,23 @@ export async function runJsBindingsPipeline(options: OrchestratorOptions): Promi // If SDK pins changed after restore, codegen would emit against stale winmd inventory. if (lockfile.yamlPackagesHash) { const currentPackages = readWinappYamlPackages(workspaceDir, options.yamlPath); - if (currentPackages) { - const currentHash = computeYamlPackagesHash(currentPackages); - if (currentHash !== lockfile.yamlPackagesHash) { - return { - outcome: 'lockfileStale', - message: - `winapp.yaml \`packages:\` has changed since the last \`winapp restore\` ` + - `(lockfile hash ${lockfile.yamlPackagesHash.slice(0, 12)}…, current ${currentHash.slice(0, 12)}…). ` + - 'Run `winapp restore` to refresh the winmd inventory before generating bindings.', - }; - } + if (!currentPackages) { + return { + outcome: 'lockfileStale', + message: + 'Lockfile records a `winapp.yaml` package hash but `winapp.yaml` could not be read. ' + + 'Restore the file (or remove `.winapp/winmds.lock.json` if intentional) before regenerating bindings.', + }; + } + const currentHash = computeYamlPackagesHash(currentPackages); + if (currentHash !== lockfile.yamlPackagesHash) { + return { + outcome: 'lockfileStale', + message: + `winapp.yaml \`packages:\` has changed since the last \`winapp restore\` ` + + `(lockfile hash ${lockfile.yamlPackagesHash.slice(0, 12)}…, current ${currentHash.slice(0, 12)}…). ` + + 'Run `winapp restore` to refresh the winmd inventory before generating bindings.', + }; } } @@ -104,12 +110,14 @@ export async function runJsBindingsPipeline(options: OrchestratorOptions): Promi // Cherry-pick entries load as refs; only listed classes are emitted. const bulkAdditional: string[] = []; - const cherryPicks: { namespace: string; classes: string[] }[] = []; - const cherryPickRefs: string[] = []; + const cherryPicks: { winmdPath: string; namespace: string; classes: string[] }[] = []; for (const entry of userEmit.resolved) { if (entry.namespace && entry.classes && entry.classes.length > 0) { - cherryPicks.push({ namespace: entry.namespace, classes: entry.classes }); - cherryPickRefs.push(entry.winmdPath); + cherryPicks.push({ + winmdPath: entry.winmdPath, + namespace: entry.namespace, + classes: entry.classes, + }); } else { bulkAdditional.push(entry.winmdPath); } @@ -119,7 +127,10 @@ export async function runJsBindingsPipeline(options: OrchestratorOptions): Promi const partition = partitionPackageWinmds(lockfile.packages); const emitWinmds = [...partition.emit, ...bulkAdditional]; - // Cherry-pick winmds are refs because only their requested classes are emitted. + // Include every cherry-pick winmd in --ref so each pass can resolve types + // declared in OTHER cherry-pick winmds. buildExtraTypeArgs strips the + // current pass's own winmd from --ref to avoid the duplicate. + const cherryPickRefs = cherryPicks.map((cp) => cp.winmdPath); const refWinmds = [...partition.refOnly, ...userRefs.resolved.map((r) => r.winmdPath), ...cherryPickRefs]; if (emitWinmds.length === 0 && cherryPicks.length === 0) { diff --git a/src/winapp-npm/src/jsbindings/package-json-config.ts b/src/winapp-npm/src/jsbindings/package-json-config.ts index a9744ef3..26ee9d4b 100644 --- a/src/winapp-npm/src/jsbindings/package-json-config.ts +++ b/src/winapp-npm/src/jsbindings/package-json-config.ts @@ -67,7 +67,8 @@ export function ensureJsBindingsBlock( writeJsBindingsConfig(workspaceDir, defaultJsBindingsConfig()); if (!opts.quiet) { console.log( - 'ℹ️ Added "winapp.jsBindings" to package.json. ' + 'Edit it to customize package scope, extraTypes, etc.' + 'ℹ️ Added "winapp.jsBindings" to package.json. ' + + 'Edit `output`, `additionalWinmds`, or `additionalRefs` to customize.' ); } return 'added'; diff --git a/src/winapp-npm/src/jsbindings/yaml-packages-hash.ts b/src/winapp-npm/src/jsbindings/yaml-packages-hash.ts index 266e8318..abf0b8ba 100644 --- a/src/winapp-npm/src/jsbindings/yaml-packages-hash.ts +++ b/src/winapp-npm/src/jsbindings/yaml-packages-hash.ts @@ -35,6 +35,17 @@ export interface PackagePin { * Compute the canonical hash for a sequence of name/version pairs. * Whitespace-only names are skipped (matching C#'s `IsNullOrWhiteSpace`). * `null`/`undefined` versions canonicalize to the empty string. + * + * Cross-language parity is pinned by `YamlPackagesHasherTests.Compute_GoldenFixture_PinsHashForCrossLanguageParity` + * on the C# side. Update both implementations together when changing + * canonicalization (the test will fail otherwise). + * + * Golden fixture (for ad-hoc verification): + * packages = [ + * { name: 'Microsoft.WindowsAppSDK', version: '2.1.3' }, + * { name: 'Microsoft.Windows.SDK.CPP', version: '10.0.28000.1839' }, + * ] + * → '8581abfcb53fa04056a066fc7098c5d94064cc275e20f0e547365c1b8b146e54' */ export function computeYamlPackagesHash(packages: Iterable): string { const lines = new Set(); From 196f3f4760f612b35469745ca5236c3fc8c61f7e Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Sat, 23 May 2026 00:17:45 +0800 Subject: [PATCH 21/27] fix parse bug --- .../src/jsbindings/codegen-runner.ts | 6 ++++ .../src/jsbindings/lockfile-reader.ts | 34 +++++++++++-------- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/winapp-npm/src/jsbindings/codegen-runner.ts b/src/winapp-npm/src/jsbindings/codegen-runner.ts index eb540bcb..c0910fff 100644 --- a/src/winapp-npm/src/jsbindings/codegen-runner.ts +++ b/src/winapp-npm/src/jsbindings/codegen-runner.ts @@ -365,6 +365,12 @@ export function resolveCodegenInvocation(): CodegenInvocation { const pkgDirs = resolveCodegenPackageDirs(wrapperDir); let lastChecked: string | null = null; for (const pkgDir of pkgDirs) { + // Refuse any pkgDir under UNC or with a reparse-point ancestor — a hostile + // npm install layout (junction'd node_modules) could redirect us to a + // victim binary. + if (isNetworkPath(pkgDir) || hasReparsePointOnPath(pkgDir, path.parse(pkgDir).root || pkgDir)) { + continue; + } // Prefer the pre-built .exe; cli.js is a defensive fallback. const exePath = path.join(pkgDir, 'bin', arch, 'dynwinrt-codegen.exe'); if (fs.existsSync(exePath)) { diff --git a/src/winapp-npm/src/jsbindings/lockfile-reader.ts b/src/winapp-npm/src/jsbindings/lockfile-reader.ts index 7ef02667..03a01dbf 100644 --- a/src/winapp-npm/src/jsbindings/lockfile-reader.ts +++ b/src/winapp-npm/src/jsbindings/lockfile-reader.ts @@ -117,6 +117,14 @@ export function tryReadLockfile(workspaceDir: string): ReadLockfileResult { : typeof obj.nugetCacheDir === 'string' ? obj.nugetCacheDir : undefined; + if (!nugetCacheDir || !nugetCacheDir.trim()) { + return { + lockfile: null, + reason: + `Lockfile ${filePath} missing nuget_cache_dir. ` + + `Re-run \`winapp restore\` to regenerate (older lockfiles without a containment boundary are unsafe).`, + }; + } const yamlPackagesHash = typeof obj.yaml_packages_hash === 'string' ? obj.yaml_packages_hash @@ -124,9 +132,7 @@ export function tryReadLockfile(workspaceDir: string): ReadLockfileResult { ? obj.yamlPackagesHash : undefined; - // Require lockfile winmds to stay under nuget_cache_dir; UNC/reparse escapes are dropped. - // Without nuget_cache_dir there is no boundary, so codegen surfaces absolute-path issues. - const cacheBoundary = nugetCacheDir ? path.resolve(nugetCacheDir).replace(/[\\/]+$/, '') : null; + const cacheBoundary = path.resolve(nugetCacheDir).replace(/[\\/]+$/, ''); const droppedPaths: string[] = []; const packages: WinmdsLockfilePackage[] = []; for (const entry of packagesRaw) { @@ -146,18 +152,16 @@ export function tryReadLockfile(workspaceDir: string): ReadLockfileResult { droppedPaths.push(w); continue; } - if (cacheBoundary) { - const resolved = path.resolve(w); - const prefix = cacheBoundary + path.sep; - const inside = resolved.length >= prefix.length && resolved.toLowerCase().startsWith(prefix.toLowerCase()); - if (!inside) { - droppedPaths.push(w); - continue; - } - if (hasReparsePointOnPath(resolved, cacheBoundary)) { - droppedPaths.push(w); - continue; - } + const resolved = path.resolve(w); + const prefix = cacheBoundary + path.sep; + const inside = resolved.length >= prefix.length && resolved.toLowerCase().startsWith(prefix.toLowerCase()); + if (!inside) { + droppedPaths.push(w); + continue; + } + if (hasReparsePointOnPath(resolved, cacheBoundary)) { + droppedPaths.push(w); + continue; } winmdsArr.push(w); } From 6dd34a8743e6ef3cd9fdac374a74ee4d3b85c5da Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Sat, 23 May 2026 01:27:10 +0800 Subject: [PATCH 22/27] fix config not match issue and runtime version auto update --- docs/guides/electron/js-bindings.md | 25 ++++---- .../src/jsbindings/additional-winmds.ts | 59 ++++++++++++++----- .../src/jsbindings/codegen-runner.ts | 13 ++-- src/winapp-npm/src/jsbindings/orchestrator.ts | 17 +++--- .../src/jsbindings/package-json-config.ts | 17 ++++-- .../src/jsbindings/runtime-dep-injector.ts | 12 +++- 6 files changed, 100 insertions(+), 43 deletions(-) diff --git a/docs/guides/electron/js-bindings.md b/docs/guides/electron/js-bindings.md index 06bffc7e..91dba70e 100644 --- a/docs/guides/electron/js-bindings.md +++ b/docs/guides/electron/js-bindings.md @@ -223,12 +223,15 @@ Full schema with every field shown explicitly: "output": "bindings", // Extra .winmd files to feed into the codegen alongside the ones - // discovered from `winapp.yaml`'s NuGet packages. Two modes per entry: - // * winmdPath only → bulk-emit the whole winmd - // * + namespace + classes → cherry-pick: only emit the listed - // classes from that namespace (the winmd - // is loaded as ref-only so codegen can - // still resolve its other types). + // discovered from `winapp.yaml`'s NuGet packages. Three entry shapes: + // * winmdPath only → bulk-emit the whole winmd + // * winmdPath + namespace + classes → cherry-pick from that winmd + // * namespace + classes only (no winmdPath) → cherry-pick from the + // Windows SDK (auto-detected + // Windows.winmd). Use this + // for `Windows.*` classes + // without hardcoding the + // SDK install path. // Paths: relative to workspace root, OR absolute. Missing files = warning. "additionalWinmds": [ { "winmdPath": "vendor/MyCompany.Foo.winmd" }, @@ -236,7 +239,9 @@ Full schema with every field shown explicitly: "winmdPath": "vendor/BigVendor.SDK.winmd", "namespace": "BigVendor.Camera", "classes": ["Lens", "Sensor"] - } + }, + { "namespace": "Windows.Storage", "classes": ["StorageFile"] }, + { "namespace": "Windows.ApplicationModel", "classes": ["LimitedAccessFeatures"] } ], // Extra .winmd files loaded for type resolution only (no emit). @@ -255,15 +260,15 @@ Full schema with every field shown explicitly: | Field | Default | Type | |-------|---------|------| | `output` | `"bindings"` | string | -| `additionalWinmds` | `[]` | array of `{winmdPath, namespace?, classes?[]}` | +| `additionalWinmds` | `[]` | array of `{winmdPath?, namespace?, classes?[]}` | | `additionalRefs` | `[]` | array of paths | ### Composition rules 1. **NuGet packages** — every package installed via `winapp.yaml` is partitioned by the built-in policy (WinUI / WebView2 = skip; InteractiveExperiences = ref-only; everything else = bulk-emit). The policy isn't user-configurable; install fewer packages in `winapp.yaml` if you want fewer bindings. -2. **`additionalWinmds`** — each entry is either bulk-emitted (no `namespace`/`classes`) or cherry-picked (with both). Cherry-pick entries load the winmd as ref-only and only emit the listed classes. +2. **`additionalWinmds`** — each entry is one of three shapes: bulk-emit a whole winmd (`winmdPath` only), cherry-pick from a specific winmd (`winmdPath` + `namespace` + `classes`), or cherry-pick from the Windows SDK (`namespace` + `classes`, no path — codegen auto-detects `Windows.winmd`). 3. **`additionalRefs`** — appended to the codegen `--ref` channel for type resolution; never emit. -4. **Codegen auto-classification** — `Windows.*` system winmds (and other foundation namespaces) are always loaded as resolution-only refs even when listed under `additionalWinmds` with no `namespace`/`classes`. Use the cherry-pick form (with `namespace` + `classes`) to pull individual classes out of them. +4. **Codegen auto-classification** — `Windows.*` system winmds (and other foundation namespaces) are always loaded as resolution-only refs in bulk. Use the path-less cherry-pick form (`{namespace, classes}`) to pull individual Windows classes out without hardcoding the SDK install path. ## Common workflows diff --git a/src/winapp-npm/src/jsbindings/additional-winmds.ts b/src/winapp-npm/src/jsbindings/additional-winmds.ts index 9be1dbc3..82ed27d4 100644 --- a/src/winapp-npm/src/jsbindings/additional-winmds.ts +++ b/src/winapp-npm/src/jsbindings/additional-winmds.ts @@ -14,14 +14,14 @@ import { isNetworkPath, hasReparsePointOnPath } from './path-safety'; * while namespace+classes cherry-picks. */ export interface AdditionalWinmd { - winmdPath: string; + winmdPath?: string; namespace?: string; classes?: string[]; } export interface ResolvedAdditionalWinmd { - /** Absolute path after UNC/reparse checks. */ - winmdPath: string; + /** Absolute path after UNC/reparse checks; undefined for auto-detect entries. */ + winmdPath?: string; namespace?: string; classes?: string[]; } @@ -46,23 +46,55 @@ export function resolveAdditionalWinmds( const workspaceFull = path.resolve(workspaceDir).replace(/[\\/]+$/, ''); for (const entry of entries) { - if (!entry || typeof entry.winmdPath !== 'string' || !entry.winmdPath.trim()) { + if (!entry) { continue; } - const trimmed = entry.winmdPath.trim(); - if (isNetworkPath(trimmed)) { + const rawPath = typeof entry.winmdPath === 'string' ? entry.winmdPath.trim() : ''; + const ns = typeof entry.namespace === 'string' ? entry.namespace.trim() : ''; + const classes = Array.isArray(entry.classes) + ? entry.classes.map((c) => (typeof c === 'string' ? c.trim() : '')).filter((c) => c.length > 0) + : []; + + // Path-less entry: rely on dynwinrt-codegen auto-detect (Windows.winmd in + // the Windows SDK) — requires namespace+classes to be useful, otherwise + // the entry has no actionable content. + if (!rawPath) { + if (!ns || classes.length === 0) { + warnings.push( + `jsBindings.${fieldName} entry has no winmdPath and no namespace+classes — skipping (nothing to generate).` + ); + continue; + } + const dedupeKey = `|${ns}`; + const existingIdx = seenIndex.get(dedupeKey); + if (existingIdx !== undefined) { + const existing = resolved[existingIdx]; + const merged = new Set(existing.classes ?? []); + for (const c of classes) { + merged.add(c); + } + existing.namespace = ns; + existing.classes = [...merged]; + continue; + } + seenIndex.set(dedupeKey, resolved.length); + resolved.push({ namespace: ns, classes }); + continue; + } + + if (isNetworkPath(rawPath)) { warnings.push( - `jsBindings.${fieldName} entry refused — network/UNC paths are not allowed (would probe attacker-controlled host). Entry: ${trimmed}` + `jsBindings.${fieldName} entry refused — network/UNC paths are not allowed (would probe attacker-controlled host). Entry: ${rawPath}` ); continue; } - const fullPath = path.isAbsolute(trimmed) ? path.resolve(trimmed) : path.resolve(workspaceFull, trimmed); + const fullPath = path.isAbsolute(rawPath) ? path.resolve(rawPath) : path.resolve(workspaceFull, rawPath); if (isNetworkPath(fullPath)) { warnings.push( - `jsBindings.${fieldName} entry resolved to UNC path; refusing to probe. Entry: ${trimmed} → ${fullPath}` + `jsBindings.${fieldName} entry resolved to UNC path; refusing to probe. Entry: ${rawPath} → ${fullPath}` ); continue; } @@ -74,18 +106,13 @@ export function resolveAdditionalWinmds( if (hasReparsePointOnPath(fullPath, reparseBoundary)) { warnings.push( - `jsBindings.${fieldName} entry refused — file or one of its ancestors up to ${reparseBoundary} is a reparse point. Entry: ${trimmed} → ${fullPath}` + `jsBindings.${fieldName} entry refused — file or one of its ancestors up to ${reparseBoundary} is a reparse point. Entry: ${rawPath} → ${fullPath}` ); continue; } - const ns = typeof entry.namespace === 'string' ? entry.namespace.trim() : ''; - const classes = Array.isArray(entry.classes) - ? entry.classes.map((c) => (typeof c === 'string' ? c.trim() : '')).filter((c) => c.length > 0) - : []; - if (!fs.existsSync(fullPath)) { - warnings.push(`jsBindings.${fieldName} entry not found, skipping: ${trimmed} (resolved to ${fullPath})`); + warnings.push(`jsBindings.${fieldName} entry not found, skipping: ${rawPath} (resolved to ${fullPath})`); continue; } diff --git a/src/winapp-npm/src/jsbindings/codegen-runner.ts b/src/winapp-npm/src/jsbindings/codegen-runner.ts index c0910fff..f11eaea3 100644 --- a/src/winapp-npm/src/jsbindings/codegen-runner.ts +++ b/src/winapp-npm/src/jsbindings/codegen-runner.ts @@ -20,7 +20,8 @@ const CODEGEN_PACKAGE_NAME = '@microsoft/dynwinrt-codegen'; /** One cherry-pick pass derived from `additionalWinmds[i]` with namespace+classes. */ export interface CodegenCherryPick { - winmdPath: string; + /** Omit to rely on dynwinrt-codegen auto-detect (Windows SDK Windows.winmd). */ + winmdPath?: string; namespace: string; classes: readonly string[]; } @@ -251,8 +252,12 @@ export function buildExtraTypeArgs( ): string[] { const args: string[] = [...prefixArgs, 'generate']; const emitSet = new Set(emitWinmds); - emitSet.add(extra.winmdPath); - args.push('--winmd', Array.from(emitSet).join(';')); + if (extra.winmdPath) { + emitSet.add(extra.winmdPath); + } + if (emitSet.size > 0) { + args.push('--winmd', Array.from(emitSet).join(';')); + } args.push( '--namespace', extra.namespace, @@ -263,7 +268,7 @@ export function buildExtraTypeArgs( '--lang', 'js' ); - const refs = refWinmds.filter((r) => r !== extra.winmdPath); + const refs = extra.winmdPath ? refWinmds.filter((r) => r !== extra.winmdPath) : refWinmds; if (refs.length > 0) { args.push('--ref', refs.join(';')); } diff --git a/src/winapp-npm/src/jsbindings/orchestrator.ts b/src/winapp-npm/src/jsbindings/orchestrator.ts index 3c040a37..abc9d78d 100644 --- a/src/winapp-npm/src/jsbindings/orchestrator.ts +++ b/src/winapp-npm/src/jsbindings/orchestrator.ts @@ -110,7 +110,7 @@ export async function runJsBindingsPipeline(options: OrchestratorOptions): Promi // Cherry-pick entries load as refs; only listed classes are emitted. const bulkAdditional: string[] = []; - const cherryPicks: { winmdPath: string; namespace: string; classes: string[] }[] = []; + const cherryPicks: { winmdPath?: string; namespace: string; classes: string[] }[] = []; for (const entry of userEmit.resolved) { if (entry.namespace && entry.classes && entry.classes.length > 0) { cherryPicks.push({ @@ -118,7 +118,7 @@ export async function runJsBindingsPipeline(options: OrchestratorOptions): Promi namespace: entry.namespace, classes: entry.classes, }); - } else { + } else if (entry.winmdPath) { bulkAdditional.push(entry.winmdPath); } } @@ -127,11 +127,14 @@ export async function runJsBindingsPipeline(options: OrchestratorOptions): Promi const partition = partitionPackageWinmds(lockfile.packages); const emitWinmds = [...partition.emit, ...bulkAdditional]; - // Include every cherry-pick winmd in --ref so each pass can resolve types - // declared in OTHER cherry-pick winmds. buildExtraTypeArgs strips the - // current pass's own winmd from --ref to avoid the duplicate. - const cherryPickRefs = cherryPicks.map((cp) => cp.winmdPath); - const refWinmds = [...partition.refOnly, ...userRefs.resolved.map((r) => r.winmdPath), ...cherryPickRefs]; + // Include every cherry-pick winmd (when path is given) in --ref so each pass + // can resolve types declared in OTHER cherry-pick winmds. + const cherryPickRefs = cherryPicks.map((cp) => cp.winmdPath).filter((p): p is string => !!p); + const refWinmds = [ + ...partition.refOnly, + ...userRefs.resolved.map((r) => r.winmdPath).filter((p): p is string => !!p), + ...cherryPickRefs, + ]; if (emitWinmds.length === 0 && cherryPicks.length === 0) { return { diff --git a/src/winapp-npm/src/jsbindings/package-json-config.ts b/src/winapp-npm/src/jsbindings/package-json-config.ts index 26ee9d4b..7789d08d 100644 --- a/src/winapp-npm/src/jsbindings/package-json-config.ts +++ b/src/winapp-npm/src/jsbindings/package-json-config.ts @@ -150,12 +150,16 @@ function coerceAdditionalWinmds(value: unknown): AdditionalWinmd[] { } const r = v as Record; const winmdPath = typeof r.winmdPath === 'string' ? r.winmdPath.trim() : ''; - if (!winmdPath) { - continue; - } const ns = typeof r.namespace === 'string' ? r.namespace.trim() : ''; const classes = coerceStringArray(r.classes); - const entry: AdditionalWinmd = { winmdPath }; + // Drop entries that have nothing to emit: no path AND no cherry-pick target. + if (!winmdPath && (!ns || classes.length === 0)) { + continue; + } + const entry: AdditionalWinmd = {}; + if (winmdPath) { + entry.winmdPath = winmdPath; + } if (ns && classes.length > 0) { entry.namespace = ns; entry.classes = classes; @@ -170,7 +174,10 @@ function serializeConfig(config: JsBindingsConfig): Record { return { output: config.output, additionalWinmds: config.additionalWinmds.map((w) => { - const entry: Record = { winmdPath: w.winmdPath }; + const entry: Record = {}; + if (w.winmdPath) { + entry.winmdPath = w.winmdPath; + } if (w.namespace && w.classes && w.classes.length > 0) { entry.namespace = w.namespace; entry.classes = [...w.classes]; diff --git a/src/winapp-npm/src/jsbindings/runtime-dep-injector.ts b/src/winapp-npm/src/jsbindings/runtime-dep-injector.ts index 4865b146..ef1f1503 100644 --- a/src/winapp-npm/src/jsbindings/runtime-dep-injector.ts +++ b/src/winapp-npm/src/jsbindings/runtime-dep-injector.ts @@ -52,9 +52,19 @@ export function ensureRuntimeDependency( const obj = doc.parsed; const deps = obj.dependencies; if (deps && typeof deps === 'object' && !Array.isArray(deps)) { - if (packageName in (deps as Record)) { + const depsRec = deps as Record; + const existing = depsRec[packageName]; + if (typeof existing === 'string' && existing === version) { return { outcome: 'alreadyPresent' }; } + if (typeof existing === 'string') { + // Different version pinned — overwrite so codegen and the runtime stay + // version-locked. Stale pins cause hard-to-diagnose ABI mismatches + // (e.g. dynwinrt panics on TypeKind variants the older runtime can't + // marshal). + mutatePackageJsonDoc(workspaceDir, (parsed) => insertOrUpdateDependency(parsed, packageName, version)); + return { outcome: 'added', pinnedVersion: version }; + } } const devDeps = obj.devDependencies; From 2328bb2c1c90e5eb7f3aefa9722e8c90024c06bc Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Mon, 1 Jun 2026 20:54:47 +0800 Subject: [PATCH 23/27] update doc and command logic --- .github/plugin/agents/winapp.agent.md | 8 +- .../skills/winapp-cli/frameworks/SKILL.md | 14 +- .../plugin/skills/winapp-cli/setup/SKILL.md | 2 +- .github/workflows/test-samples.yml | 6 +- .gitignore | 3 + .../fragments/skills/winapp-cli/frameworks.md | 12 +- docs/fragments/skills/winapp-cli/setup.md | 2 +- docs/guides/electron/index.md | 10 +- docs/guides/electron/js-bindings.md | 432 ------------------ docs/guides/electron/js-file-picker.md | 194 ++++++++ docs/guides/electron/setup.md | 7 + docs/usage.md | 62 ++- samples/electron/src/index.html | 6 + samples/electron/src/index.js | 25 + samples/electron/src/preload.js | 1 + samples/electron/test.Tests.ps1 | 28 +- scripts/build-cli.ps1 | 19 + scripts/generate-llm-docs.ps1 | 2 +- .../WinmdsLockfileServiceTests.cs | 3 +- .../Services/PackageLayoutService.cs | 21 + .../Services/WinmdsLockfileService.cs | 18 +- src/winapp-npm/package-lock.json | 8 +- src/winapp-npm/package.json | 3 +- src/winapp-npm/src/cli-args.ts | 5 + src/winapp-npm/src/cli.ts | 116 ++++- src/winapp-npm/src/cpp-addon-utils.ts | 35 +- src/winapp-npm/src/cs-addon-utils.ts | 54 +-- .../src/jsbindings/codegen-runner.ts | 60 ++- src/winapp-npm/src/jsbindings/init-prompt.ts | 18 + src/winapp-npm/src/jsbindings/orchestrator.ts | 96 +++- .../src/jsbindings/package-json-config.ts | 11 +- .../src/jsbindings/package-json-doc.ts | 11 +- .../jsbindings/package-manager-detector.ts | 24 +- src/winapp-npm/src/jsbindings/path-safety.ts | 2 +- .../src/jsbindings/runtime-dep-injector.ts | 25 +- .../src/jsbindings/runtime-installer.ts | 65 +++ src/winapp-npm/src/winapp-commands.ts | 2 +- src/winapp-npm/test/cli-args.test.ts | 77 ++++ src/winapp-npm/test/esm-marker.test.ts | 63 +++ src/winapp-npm/test/lockfile-reader.test.ts | 97 ++++ .../test/package-manager-detector.test.ts | 75 +++ src/winapp-npm/test/path-safety.test.ts | 34 ++ .../test/yaml-packages-hash.test.ts | 95 ++++ src/winapp-npm/tsconfig.test.json | 12 + 44 files changed, 1232 insertions(+), 631 deletions(-) delete mode 100644 docs/guides/electron/js-bindings.md create mode 100644 docs/guides/electron/js-file-picker.md create mode 100644 src/winapp-npm/src/jsbindings/runtime-installer.ts create mode 100644 src/winapp-npm/test/cli-args.test.ts create mode 100644 src/winapp-npm/test/esm-marker.test.ts create mode 100644 src/winapp-npm/test/lockfile-reader.test.ts create mode 100644 src/winapp-npm/test/package-manager-detector.test.ts create mode 100644 src/winapp-npm/test/path-safety.test.ts create mode 100644 src/winapp-npm/test/yaml-packages-hash.test.ts create mode 100644 src/winapp-npm/tsconfig.test.json diff --git a/.github/plugin/agents/winapp.agent.md b/.github/plugin/agents/winapp.agent.md index 18b18f9d..09395cee 100644 --- a/.github/plugin/agents/winapp.agent.md +++ b/.github/plugin/agents/winapp.agent.md @@ -218,17 +218,15 @@ Want to inspect or interact with a running app's UI? ### Electron - **Setup:** `winapp init --use-defaults` → choose your Windows API access path: - - **JS bindings** — typed `bindings/*.{js,d.ts}` covering the Windows App SDK, called via `@microsoft/dynwinrt` (no native build step). - - **Add:** `npx winapp init` (interactive prompt) or `npx winapp node generate-bindings` on an existing project. Both write a default `winapp.jsBindings` namespace into `package.json` if missing, then generate. - - **Re-run:** `npx winapp node generate-bindings` after editing `winapp.jsBindings.{packages,extraTypes,additionalWinmds}`. Use `npx winapp restore` instead when you changed `winapp.yaml`. - - Codegen injects `@microsoft/dynwinrt` as a production dep — run `npm install` afterwards to materialize it. + - **JS bindings:** typed `.winapp/bindings/*.{js,d.ts}` for the Windows App SDK via the `@microsoft/dynwinrt` runtime (no native build step). Opt in at the `winapp init` prompt above. - **Native addons:** `winapp node create-addon --template cs` (or `--template cpp`) for C#/C++ addons when you need full WinRT access or stateful native services. - **Package:** Build with your packager (e.g., Electron Forge), then `winapp package --cert .\devcert.pfx` - Use `winapp node create-addon` to create native C#/C++ addons for Windows APIs +- Regenerate bindings after edits: `npx winapp restore` for `winapp.yaml` changes (also refreshes bindings), or the faster `npx winapp node generate-bindings` for `winapp.jsBindings`-only changes. - Use `winapp node add-electron-debug-identity` / `clear-electron-debug-identity` for identity management - **⚠️ Always run `npx winapp node add-electron-debug-identity` before testing any Windows API that requires package identity** — without this, APIs will fail at runtime - Guide: https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/setup.md -- JS bindings reference: https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/js-bindings.md +- JS bindings reference: https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/js-file-picker.md ### .NET (WPF, WinForms, Console) - **Setup:** `winapp init --use-defaults` — but if you already have a `Package.appxmanifest` (e.g., WinUI 3 apps), you likely **don't need `winapp init`**. Just ensure your `.csproj` references the `Microsoft.WindowsAppSDK` NuGet package and has the right properties for packaged builds. diff --git a/.github/plugin/skills/winapp-cli/frameworks/SKILL.md b/.github/plugin/skills/winapp-cli/frameworks/SKILL.md index dd952dd8..6c835a14 100644 --- a/.github/plugin/skills/winapp-cli/frameworks/SKILL.md +++ b/.github/plugin/skills/winapp-cli/frameworks/SKILL.md @@ -1,6 +1,6 @@ --- name: winapp-frameworks -description: Framework-specific Windows development guidance for Electron, .NET (WPF, WinForms), C++, Rust, Flutter, and Tauri. Use when packaging or adding Windows features to an Electron app, .NET desktop app, Flutter app, Tauri app, Rust app, or C++ app. +description: Framework-specific Windows development guidance for Electron, .NET (WPF, WinForms), C++, Rust, Flutter, and Tauri, including JS bindings for WinRT APIs and native addons. Use when packaging or adding Windows features to an Electron app, .NET desktop app, Flutter app, Tauri app, Rust app, or C++ app. version: 0.3.2 --- ## When to use @@ -30,22 +30,22 @@ Use the **npm package** (`@Microsoft/WinAppCli`), **not** the standalone CLI. Th - The native winapp CLI binary bundled inside `node_modules` - A Node.js SDK with helpers for creating native C#/C++ addons - Electron-specific commands under `npx winapp node` -- Typed JS/TypeScript WinRT bindings via dynwinrt (no native build required), opt-in during `npx winapp init` or via `npx winapp node generate-bindings` +- Commands for generating typed JS bindings (no native build required) Quick start: ```powershell npm install --save-dev @microsoft/winappcli npx winapp init --use-defaults # fresh init: scaffolds winapp.yaml + JS bindings + C++ projections -npx winapp node generate-bindings # existing project: add (or re-run) JS bindings only -npx winapp node create-addon --template cs # create a C# native addon (for what dynwinrt can't drive — see below) +npx winapp node generate-bindings # existing project: regenerate JS bindings after editing winapp.jsBindings +npx winapp node create-addon --template cs # create a C# native addon (for what the JS bindings can't drive — see below) npx winapp node add-electron-debug-identity # register identity for debugging ``` -#### Choosing between jsBindings and a native addon +#### Choosing between JS bindings and a native addon The decision is about the **shape of the API**, not preference. -**Default — WinRT API (ships in a `.winmd`) → JS bindings.** Covers most of `Microsoft.Windows.*` (Notifications, FilePickers, Sensors, AI like `TextRecognizer` / `LanguageModel`), `Windows.*`, and `Microsoft.WindowsAppSDK.AI`. See [dynwinrt scope](https://github.com/microsoft/dynwinrt#scope). +**Default — WinRT API (ships in a `.winmd`) → JS bindings.** Covers most of `Microsoft.Windows.*` (Notifications, FilePickers, Sensors, AI like `TextRecognizer` / `LanguageModel`), `Windows.*`, and `Microsoft.WindowsAppSDK.AI`. See [`@microsoft/dynwinrt` scope](https://github.com/microsoft/dynwinrt#scope). **Fall back to `node create-addon` when there's no `.winmd`:** @@ -58,7 +58,7 @@ The decision is about the **shape of the API**, not preference. Mixing both in one app is normal. Additional Electron guides: -- [JS bindings guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/js-bindings.md) — full `winapp.jsBindings` JSON schema, per-package classification, lockfile +- [JS bindings guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/js-file-picker.md) — end-to-end workflow for calling WinRT from JS/TS, including binding scope configuration - [Packaging guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/packaging.md) - [C++ notification addon guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/cpp-notification-addon.md) - [WinML addon guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/winml-addon.md) diff --git a/.github/plugin/skills/winapp-cli/setup/SKILL.md b/.github/plugin/skills/winapp-cli/setup/SKILL.md index 99c54444..d7e19bc6 100644 --- a/.github/plugin/skills/winapp-cli/setup/SKILL.md +++ b/.github/plugin/skills/winapp-cli/setup/SKILL.md @@ -58,7 +58,7 @@ After `init`, your project will contain: - `winapp.yaml` — SDK version pinning for `restore`/`update` - `.winapp/` — downloaded SDK packages and generated projections - `.gitignore` update — excludes `.winapp/` and `devcert.pfx` -- `bindings/` — typed JS/TS WinRT projections (npm-only, Node / Electron) +- `.winapp/bindings/` — generated JS bindings for WinRT APIs (npm-only, Node / Electron) - `package.json` update — adds the `winapp.jsBindings` namespace and `@microsoft/dynwinrt` dependency (npm-only) ### Restore after cloning diff --git a/.github/workflows/test-samples.yml b/.github/workflows/test-samples.yml index 924e216a..4cdb3890 100644 --- a/.github/workflows/test-samples.yml +++ b/.github/workflows/test-samples.yml @@ -149,7 +149,11 @@ jobs: if: steps.check.outputs.skip != 'true' shell: pwsh run: | - Install-Module -Name Pester -Force -SkipPublisherCheck -Scope CurrentUser -MinimumVersion 5.0 + if (-not (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) { + Register-PSRepository -Default + } + Set-PSRepository -Name PSGallery -InstallationPolicy Trusted + Install-Module -Name Pester -Force -SkipPublisherCheck -Scope CurrentUser -MinimumVersion 5.0 -Repository PSGallery - name: Run ${{ matrix.sample }} test if: steps.check.outputs.skip != 'true' diff --git a/.gitignore b/.gitignore index d962b820..5f3e171d 100644 --- a/.gitignore +++ b/.gitignore @@ -85,6 +85,9 @@ out .nuxt dist +# Compiled TypeScript unit tests (src/winapp-npm) +dist-test + # Gatsby files .cache/ # Comment in the public line in if your project uses Gatsby and not Next.js diff --git a/docs/fragments/skills/winapp-cli/frameworks.md b/docs/fragments/skills/winapp-cli/frameworks.md index 59898372..b939d91b 100644 --- a/docs/fragments/skills/winapp-cli/frameworks.md +++ b/docs/fragments/skills/winapp-cli/frameworks.md @@ -25,22 +25,22 @@ Use the **npm package** (`@Microsoft/WinAppCli`), **not** the standalone CLI. Th - The native winapp CLI binary bundled inside `node_modules` - A Node.js SDK with helpers for creating native C#/C++ addons - Electron-specific commands under `npx winapp node` -- Typed JS/TypeScript WinRT bindings via dynwinrt (no native build required), opt-in during `npx winapp init` or via `npx winapp node generate-bindings` +- Commands for generating typed JS bindings (no native build required) Quick start: ```powershell npm install --save-dev @microsoft/winappcli npx winapp init --use-defaults # fresh init: scaffolds winapp.yaml + JS bindings + C++ projections -npx winapp node generate-bindings # existing project: add (or re-run) JS bindings only -npx winapp node create-addon --template cs # create a C# native addon (for what dynwinrt can't drive — see below) +npx winapp node generate-bindings # existing project: regenerate JS bindings after editing winapp.jsBindings +npx winapp node create-addon --template cs # create a C# native addon (for what the JS bindings can't drive — see below) npx winapp node add-electron-debug-identity # register identity for debugging ``` -#### Choosing between jsBindings and a native addon +#### Choosing between JS bindings and a native addon The decision is about the **shape of the API**, not preference. -**Default — WinRT API (ships in a `.winmd`) → JS bindings.** Covers most of `Microsoft.Windows.*` (Notifications, FilePickers, Sensors, AI like `TextRecognizer` / `LanguageModel`), `Windows.*`, and `Microsoft.WindowsAppSDK.AI`. See [dynwinrt scope](https://github.com/microsoft/dynwinrt#scope). +**Default — WinRT API (ships in a `.winmd`) → JS bindings.** Covers most of `Microsoft.Windows.*` (Notifications, FilePickers, Sensors, AI like `TextRecognizer` / `LanguageModel`), `Windows.*`, and `Microsoft.WindowsAppSDK.AI`. See [`@microsoft/dynwinrt` scope](https://github.com/microsoft/dynwinrt#scope). **Fall back to `node create-addon` when there's no `.winmd`:** @@ -53,7 +53,7 @@ The decision is about the **shape of the API**, not preference. Mixing both in one app is normal. Additional Electron guides: -- [JS bindings guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/js-bindings.md) — full `winapp.jsBindings` JSON schema, per-package classification, lockfile +- [JS bindings guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/js-file-picker.md) — end-to-end workflow for calling WinRT from JS/TS, including binding scope configuration - [Packaging guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/packaging.md) - [C++ notification addon guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/cpp-notification-addon.md) - [WinML addon guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/electron/winml-addon.md) diff --git a/docs/fragments/skills/winapp-cli/setup.md b/docs/fragments/skills/winapp-cli/setup.md index 805ee027..59b4f663 100644 --- a/docs/fragments/skills/winapp-cli/setup.md +++ b/docs/fragments/skills/winapp-cli/setup.md @@ -53,7 +53,7 @@ After `init`, your project will contain: - `winapp.yaml` — SDK version pinning for `restore`/`update` - `.winapp/` — downloaded SDK packages and generated projections - `.gitignore` update — excludes `.winapp/` and `devcert.pfx` -- `bindings/` — typed JS/TS WinRT projections (npm-only, Node / Electron) +- `.winapp/bindings/` — generated JS bindings for WinRT APIs (npm-only, Node / Electron) - `package.json` update — adds the `winapp.jsBindings` namespace and `@microsoft/dynwinrt` dependency (npm-only) ### Restore after cloning diff --git a/docs/guides/electron/index.md b/docs/guides/electron/index.md index dc4edaeb..222c2dd5 100644 --- a/docs/guides/electron/index.md +++ b/docs/guides/electron/index.md @@ -38,13 +38,13 @@ First, you'll set up your development environment with the necessary tools and S Next, choose how to call Windows APIs from your Electron app: -#### Option A: [JS/TypeScript bindings via dynwinrt](js-bindings.md) ✨ *new* +#### Option A: [JS bindings](js-file-picker.md) ✨ *new* -The simplest path — typed JS/TypeScript wrappers generated from `.winmd` metadata, no native build step required from your Electron project. Opt in during `npx winapp init` (or pass `--use-defaults` to auto-accept) and a `bindings/` directory is dropped next to your sources. You `import { ChatClient } from './bindings'` and call WinRT directly. +The simplest path — typed JS bindings generated from `.winmd` metadata, no native build step required from your Electron project. Opt in during `npx winapp init` (or pass `--use-defaults` to auto-accept) and a `.winapp/bindings/` directory is added to your project. You can then add `import { ChatClient } from './.winapp/bindings'` and call WinRT directly. -[Add JS bindings →](js-bindings.md) +[Add JS bindings →](js-file-picker.md) -> Native addons (Options B–D below) are still the right choice when the API has no WinRT projection — Win32 / pure COM (raw `IFileDialog`, registry, custom COM servers), C++ libraries that ship only headers + a static/shared lib, or vendor SDKs that ship only a managed .NET assembly. For everything that ships in a `.winmd`, jsBindings is the easier on-ramp. +> Native addons (Options B–D below) are still the right choice when the API has no WinRT projection — Win32 / pure COM (raw `IFileDialog`, registry, custom COM servers), C++ libraries that ship only headers + a static/shared lib, or vendor SDKs that ship only a managed .NET assembly. For everything that ships in a `.winmd`, JS bindings are the easier option. #### Option B: [Creating a C++ Notification Addon](cpp-notification-addon.md) Learn how to create a C++ addon that calls the Windows App SDK notification APIs. This is a great starting point for understanding native addons before diving into more complex scenarios. @@ -76,7 +76,7 @@ Finally, you'll package your app as an MSIX for distribution. This includes: | Phase | Guide | What You'll Learn | |-------|-------|-------------------| | 1️⃣ | [Setup](setup.md) | Install tools, initialize SDKs, configure build pipeline | -| 2️⃣ | [JS bindings (dynwinrt)](js-bindings.md) | Generate typed JS/TS WinRT wrappers, no native build step | +| 2️⃣ | [JS bindings](js-file-picker.md) | Generate typed JS bindings, no native build step | | 2️⃣ | [C++ Notification Addon](cpp-notification-addon.md) | Create C++ addon, call notification APIs, test with debug identity | | 2️⃣ | [Phi Silica Addon](phi-silica-addon.md) | Create C# addon, call AI APIs, test with debug identity | | 2️⃣ | [WinML Addon](winml-addon.md) | Create C# addon, call WinML APIs, run ONNX models, integrate ML | diff --git a/docs/guides/electron/js-bindings.md b/docs/guides/electron/js-bindings.md deleted file mode 100644 index 91dba70e..00000000 --- a/docs/guides/electron/js-bindings.md +++ /dev/null @@ -1,432 +0,0 @@ - -# Calling WinRT APIs from JavaScript (JS / TypeScript bindings) - -Call modern Windows Runtime (WinRT) APIs directly from JavaScript or TypeScript — **no native addon, no `node-gyp` / MSBuild step, full IntelliSense**. - -`winapp` does this by running [`@microsoft/dynwinrt-codegen`](https://www.npmjs.com/package/@microsoft/dynwinrt-codegen) against your `.winmd` metadata to emit typed `.js` + `.d.ts` bindings; at runtime they delegate to [`@microsoft/dynwinrt`](https://www.npmjs.com/package/@microsoft/dynwinrt). - -> **When to choose JS bindings over a native addon:** when the API ships in a `.winmd` (most of `Windows.*` and `Microsoft.WindowsAppSDK.*`). Reach for a native addon only when there's no WinRT projection — Win32 / pure COM (raw `IFileDialog`, registry, custom COM servers), C++ libraries that ship only headers + a static/shared lib, or vendor SDKs that ship only a managed .NET assembly. See the C++ / C# addon guides for those cases. - -## Prerequisites - -Before starting this guide, make sure you've: -- Completed the [development environment setup](setup.md) -- Used `winapp` via `npx` (the `@microsoft/winappcli` npm package) — JS bindings only work through the npm shim; the standalone winget / installer build doesn't surface them. - -## Step 1: Add JS bindings to your project - -You have two paths depending on whether your Electron app already has a `winapp.yaml`. - -### Path A — Fresh project (init with bindings) - -Run `npx winapp init` and opt in to JS bindings (the interactive prompt defaults to Yes; pass `--use-defaults` to auto-accept in scripted / CI runs). `init` installs the WinAppSDK packages, adds a default `"winapp": { "jsBindings": {} }` namespace to `package.json` (covering the full Windows App SDK), and runs the codegen. - -```bash -npx winapp init --use-defaults -npm install # materializes the @microsoft/dynwinrt runtime dep -``` - -### Path B — Existing project (layer bindings on) - -If `winapp.yaml` already exists and you want to add JS bindings, run `generate-bindings`. The first invocation adds a default `winapp.jsBindings` namespace to `package.json` (covering the full Windows App SDK) and then generates immediately from the winmd lockfile written by your last `winapp restore`: - -```bash -npx winapp node generate-bindings -npm install # materializes the @microsoft/dynwinrt runtime dep -``` - -If you want to customize the scope before the first generation, you can still edit `package.json` directly — the empty form covers the full Windows App SDK: - -```jsonc -// package.json -{ - "winapp": { - "jsBindings": {} - } -} -``` - -…and then run `npx winapp node generate-bindings` (or `npx winapp restore` if you also need to refresh NuGet packages / the winmd lockfile). - -### What you get - -Both paths produce a `bindings/` directory next to your sources: - -``` -bindings/ -├── index.js # entry — re-exports every emitted class -├── index.d.ts # TS bundle -├── FileOpenPicker.js # one pair of files per emitted class -├── FileOpenPicker.d.ts -├── PickerLocationId.js -├── PickerLocationId.d.ts -└── … -``` - -To put them somewhere else, set `output` inside `winapp.jsBindings` in `package.json` (e.g. `"output": "src/generated/winrt"`) and re-run `restore`. - -> [!NOTE] -> If you need to slice generation by NuGet package, add your own `.winmd`, or cherry-pick a few classes from a giant vendor SDK, see [Common workflows](#common-workflows) and the [`package.json` schema](#packagejson--winappjsbindings-namespace) below. This walkthrough sticks to the simplest default-scope flow. - -## Step 2: Call a WinRT API from your Electron code - -Import from the generated `index.js` — you don't need to know which file inside `bindings/` a class lives in. Here's a native file picker (`Microsoft.Windows.Storage.Pickers.FileOpenPicker`) opened from your Electron main process. This API works on any Windows 11 machine once you've wired up debug identity in [Step 3](#step-3-run-it): - -```js -// src/main.js (Electron main) -import { app, BrowserWindow, ipcMain } from 'electron'; -import { - FileOpenPicker, - PickerLocationId, - PickerViewMode, -} from '../bindings/index.js'; - -async function pickAnImage(mainWindow) { - // FileOpenPicker needs the parent window's HWND wrapped in a WindowId struct. - // Electron's getNativeWindowHandle() returns an 8-byte buffer on 64-bit Windows. - const hwnd = mainWindow.getNativeWindowHandle().readBigUInt64LE(0); - - const picker = FileOpenPicker.createInstance({ value: hwnd }); - picker.viewMode = PickerViewMode.Thumbnail; - picker.suggestedStartLocation = PickerLocationId.PicturesLibrary; - picker.fileTypeFilter.replaceAll(['.png', '.jpg', '.jpeg', '.gif']); - - const result = await picker.pickSingleFileAsync(); - return result?.path; // string with the chosen path, or undefined if the user cancelled -} - -// Expose it to the renderer via IPC so a button click can trigger the picker. -ipcMain.handle('pick-image', (event) => { - const win = BrowserWindow.fromWebContents(event.sender); - return pickAnImage(win); -}); -``` - -Then bridge it into the renderer through your preload script: - -```js -// src/preload.js -import { contextBridge, ipcRenderer } from 'electron'; - -contextBridge.exposeInMainWorld('winapp', { - pickImage: () => ipcRenderer.invoke('pick-image'), -}); -``` - -…and call it from the renderer (e.g. on a button click): - -```html - - -``` - -```js -// src/renderer.js -document.querySelector('#pick-image-btn').addEventListener('click', async () => { - const path = await window.winapp.pickImage(); - console.log(path ? `Picked: ${path}` : 'Picker cancelled.'); -}); -``` - -See [Electron's IPC docs](https://www.electronjs.org/docs/latest/tutorial/ipc) for the broader pattern (preload + `contextBridge` is the recommended way to expose any main-process API — WinRT or otherwise — to your UI). - -> [!IMPORTANT] -> **Using Vite (the current `electron-forge` default)?** Externalize `@microsoft/dynwinrt` in `vite.main.config.mjs`, otherwise the build fails with `Unexpected character '\0'`: -> -> ```js -> import { defineConfig } from 'vite'; -> -> export default defineConfig({ -> build: { -> rollupOptions: { -> external: ['@microsoft/dynwinrt'], -> }, -> }, -> }); -> ``` -> -> The `electron-forge` Webpack template (`--template=webpack` / `--template=webpack-typescript`) works out of the box — no config changes needed. - -> [!NOTE] -> **Which import syntax should I use?** Check your `package.json`: -> -> - **Has a bundler plugin** (`@electron-forge/plugin-vite`, `@electron-forge/plugin-webpack`, etc. — this is the **current `electron-forge` default**): use the top-level `import` shown above as-is. Done. -> - **No bundler** (older vanilla `electron-forge` template, or a hand-rolled Node project): pick one: -> - **Switch to ESM** — add `"type": "module"` to your `package.json`, then rename `forge.config.js` → `forge.config.cjs` and (if you have it) `scripts/postinstall.js` → `.cjs`. Then the `import` above works. -> - **Stay CommonJS** — replace the static `import` with a dynamic one inside an `async` function: -> ```js -> const { FileOpenPicker, PickerLocationId, PickerViewMode } = await import('../bindings/index.js'); -> ``` -> (Top-level `await` doesn't work in CommonJS, so the call must be inside `async`.) - -The same `bindings/index.js` re-exports every other emitted class — `AppNotificationManager`, `PowerManager`, `WidgetManager`, and so on. Import what you need; the codegen has already generated typed declarations for everything in your `winapp.jsBindings` scope. - -A few conventions to remember: - -- **Names are camelCase**, with a trailing underscore when they collide with JS keywords. WinRT `ViewMode` → `viewMode`; reserved words like `default`, `arguments`, `delete` are renamed `default_`, `arguments_`, `delete_`. -- **Construct via static factories, not `new`.** Use `FileOpenPicker.createInstance(windowId)`; WinRT constructor overloads are disambiguated with suffixed names like `createInstance(content)` / `createDefault()`. -- **`UInt64` / `Int64` struct fields and method parameters are typed `bigint`, not `number`.** Use `buffer.readBigUInt64LE(0)` to widen raw OS handles, and build struct values literally — `{ value: hwnd }` — when the WinRT side expects a `WindowId`-style wrapper. -- **Async methods return a `Promise`; pass an `AbortSignal` as the last argument for cancellation:** `await picker.pickSingleFileAsync(signal)`. Operations exposing progress return `WinRTAsyncWithProgress` — both `await`-able and exposing `op.progress(cb)` for streaming updates (long downloads, AI token streams). -- **Collections (`IVector_*`, `IMap_*`, `IVectorView_*`) come with JS-friendly helpers** alongside the raw WinRT API: `picker.fileTypeFilter.replaceAll(['*'])`, `vec.toArray()`, `for (const x of vec) …`, `vec.size`. - -Events follow an `on(handler)` shape that returns an unsubscribe function (`const off = obj.onSomething(cb); /* … */ off()`), and `IDisposable` WinRT objects should be wrapped in `try/finally` with a `.close()` call. - -You can call the same API from the renderer via `contextBridge` / `ipcRenderer` — exactly as you would for a native addon. The bindings have no dependency on Electron's main process; they work anywhere Node.js can load ES modules. - -## Step 3: Run it - -WinRT APIs that require an MSIX package identity (notifications, file pickers, …) need debug identity in development. See [Step 5 of the Electron setup guide](setup.md#step-5-understanding-debug-identity) for the full explanation; if you haven't already wired it up, the one-shot command is: - -```bash -npx winapp node add-electron-debug-identity -``` - -> [!NOTE] -> This is already part of the `postinstall` script added during setup, so it usually runs automatically on `npm install`. Re-run it manually whenever you change `Package.appxmanifest`, refresh app assets, or do a clean install. - -Now start the app: - -```bash -npm start -``` - -The first call to a WinRT method imported from `bindings/` will load `@microsoft/dynwinrt` and dispatch into the underlying WinRT API — transparent to your code. - -## Step 4 (optional): Regenerate after a metadata change - -The generated `bindings/` files are build artifacts — gitignore them, or commit for diff visibility, your call. Re-run codegen whenever you change `winapp.yaml` (`packages`, `sdkVersion`, …) or `winapp.jsBindings` in `package.json`: - -```bash -# Full restore — refreshes the lockfile (NuGet + cppwinrt headers) and re-runs codegen. -# Use whenever you change `winapp.yaml`. -npx winapp restore - -# Fast path — only re-runs dynwinrt-codegen against the cached lockfile. -# Use after editing only `winapp.jsBindings`. -npx winapp node generate-bindings -``` - -## `package.json` — `winapp.jsBindings` namespace - -> **Configuration lives in `package.json`, not `winapp.yaml`.** `winapp.yaml` is owned by the native CLI and only describes SDK package pins; the JS bindings schema lives under `"winapp": { "jsBindings": {...} }` in `package.json` — the same convention used by `eslint`, `jest`, `prettier`, `tsup`, etc. The native CLI has zero awareness of JS bindings. - -Full schema with every field shown explicitly: - -```jsonc -// package.json -{ - "name": "my-electron-app", - "version": "0.1.0", - "winapp": { - "jsBindings": { - // Output directory for generated .js + .d.ts (relative to workspace root). - "output": "bindings", - - // Extra .winmd files to feed into the codegen alongside the ones - // discovered from `winapp.yaml`'s NuGet packages. Three entry shapes: - // * winmdPath only → bulk-emit the whole winmd - // * winmdPath + namespace + classes → cherry-pick from that winmd - // * namespace + classes only (no winmdPath) → cherry-pick from the - // Windows SDK (auto-detected - // Windows.winmd). Use this - // for `Windows.*` classes - // without hardcoding the - // SDK install path. - // Paths: relative to workspace root, OR absolute. Missing files = warning. - "additionalWinmds": [ - { "winmdPath": "vendor/MyCompany.Foo.winmd" }, - { - "winmdPath": "vendor/BigVendor.SDK.winmd", - "namespace": "BigVendor.Camera", - "classes": ["Lens", "Sensor"] - }, - { "namespace": "Windows.Storage", "classes": ["StorageFile"] }, - { "namespace": "Windows.ApplicationModel", "classes": ["LimitedAccessFeatures"] } - ], - - // Extra .winmd files loaded for type resolution only (no emit). - // Use for shared dependency winmds your `additionalWinmds` entries - // reference but you don't want bindings for. - "additionalRefs": [ - "vendor/BigVendor.Common.winmd" - ] - } - } -} -``` - -### Field defaults at a glance - -| Field | Default | Type | -|-------|---------|------| -| `output` | `"bindings"` | string | -| `additionalWinmds` | `[]` | array of `{winmdPath?, namespace?, classes?[]}` | -| `additionalRefs` | `[]` | array of paths | - -### Composition rules - -1. **NuGet packages** — every package installed via `winapp.yaml` is partitioned by the built-in policy (WinUI / WebView2 = skip; InteractiveExperiences = ref-only; everything else = bulk-emit). The policy isn't user-configurable; install fewer packages in `winapp.yaml` if you want fewer bindings. -2. **`additionalWinmds`** — each entry is one of three shapes: bulk-emit a whole winmd (`winmdPath` only), cherry-pick from a specific winmd (`winmdPath` + `namespace` + `classes`), or cherry-pick from the Windows SDK (`namespace` + `classes`, no path — codegen auto-detects `Windows.winmd`). -3. **`additionalRefs`** — appended to the codegen `--ref` channel for type resolution; never emit. -4. **Codegen auto-classification** — `Windows.*` system winmds (and other foundation namespaces) are always loaded as resolution-only refs in bulk. Use the path-less cherry-pick form (`{namespace, classes}`) to pull individual Windows classes out without hardcoding the SDK install path. - -## Common workflows - -### Generate bindings for the full WinAppSDK surface - -```jsonc -// package.json -{ - "winapp": { - "jsBindings": {} - } -} -``` - -The empty block accepts the defaults: `output: "bindings"`, and every package installed via `winapp.yaml` is bulk-emitted. - -> XAML namespaces (`Microsoft.UI.Xaml.*`, `Windows.UI.Xaml.*`) are out of scope for dynwinrt — the codegen itself classifies them automatically as resolution-only refs, so no JS gets emitted for them regardless of which packages are installed. - -### Add your own / a vendor `.winmd` - -```jsonc -{ - "winapp": { - "jsBindings": { - "additionalWinmds": [ - { "winmdPath": "vendor/MyCompany.Foo.winmd" }, // relative to workspace root - { "winmdPath": "C:/shared/OtherSdk.winmd" } // absolute also works - ] - } - } -} -``` - -The winmd is bulk-emitted: every public class inside gets a JS + `.d.ts` pair. - -### Cherry-pick a few classes from a giant vendor SDK - -```jsonc -{ - "winapp": { - "jsBindings": { - "additionalWinmds": [ - { - "winmdPath": "vendor/BigVendor.SDK.winmd", - "namespace": "BigVendor.Camera", - "classes": ["Lens", "Sensor"] - } - ] - } - } -} -``` - -The winmd is loaded for type resolution, but only `BigVendor.Camera.Lens` and `BigVendor.Camera.Sensor` get JS bindings emitted. The same pattern works for cherry-picking from system `Windows.*` winmds. - -### Override the output directory - -```jsonc -{ - "winapp": { - "jsBindings": { - "output": "src/generated/winrt" - } - } -} -``` - -## Runtime dependency injection - -When `init` (or `restore`) runs the JS-bindings step on a workspace, the CLI: - -1. Detects your project's package manager from the `packageManager` field in `package.json`, then falls back to lockfile sniffing (`pnpm-lock.yaml` → pnpm, `yarn.lock` → yarn, `bun.lockb` → bun, `package-lock.json` → npm). Defaults to **npm** if no signal exists. -2. Adds `@microsoft/dynwinrt` to your `package.json` `dependencies` (production dep, NOT devDep) — your generated bindings `import` from it at module load, so it must ship in your installed app. -3. Prints a PM-aware install hint (`npm install` / `pnpm install` / `yarn install` / `bun install`) so you know what to run next. - -Supported package managers: **npm, pnpm, yarn, bun**. - -> Why production not devDep? `@microsoft/dynwinrt` is the runtime that powers the generated bindings — without it, your generated `bindings/*.js` files fail to load at runtime. It's not a build-only tool. - -## How it works under the hood - -``` - ┌─────────────────────┐ ┌─────────────────────────────┐ - │ winapp.yaml │ │ package.json │ - │ (native CLI owns) │ │ "winapp": { "jsBindings" } │ - │ packages: ... │ │ (npm wrapper owns) │ - └──────────┬──────────┘ └──────────────┬──────────────┘ - │ │ - │ (winapp restore) │ (npm wrapper post-restore) - ▼ ▼ - ┌──────────────────────────────────────────┐ - │ WorkspaceSetupService (native) │ - │ • restore NuGet packages │ - │ • discover .winmd files │ - │ • write .winapp/winmds.lock.json │ - │ • generate cppwinrt projections │ - └──────────────────────────────────────────┘ - │ - ▼ (npm wrapper sees winapp.jsBindings in package.json) - ┌──────────────────────────────────────────┐ - │ JS bindings orchestrator (npm wrapper) │ - │ • partition winmds: emit / ref-only / │ - │ skip (per built-in winmd-policy) │ - │ • resolve additionalWinmds / │ - │ additionalRefs paths │ - │ • safety-check output dir │ - │ (.dynwinrt-managed marker) │ - │ • spawn @microsoft/dynwinrt-codegen │ - │ --winmd "p1;p2;..." --ref "r1;..." │ - │ • write .dynwinrt-managed marker │ - └──────────┬───────────────────────────────┘ - │ - ▼ - ┌──────────────────────────────────────────┐ - │ @microsoft/dynwinrt-codegen │ - │ • loads emit winmds + ref winmds │ - │ • auto-classifies Windows.* as │ - │ resolution-only refs │ - │ • generates .js + .d.ts │ → bindings/..{js,d.ts} - └──────────────────────────────────────────┘ - │ (at app runtime) - ▼ - ┌──────────────────────────────────────────┐ - │ @microsoft/dynwinrt │ (production dep injected into your package.json) - │ • dynamic WinRT invocation │ - │ • COM marshaling, async, delegates │ - └──────────────────────────────────────────┘ -``` - -### Per-package winmd categorization - -Some WinAppSDK packages ship `.winmd` files that dynwinrt cannot drive at runtime (XAML composables, UI Composition primitives). To keep the generated tree usable, winapp applies a **package-level policy** before handing winmds to the codegen: - -| Package | Category | Why | -|---------|----------|-----| -| `Microsoft.WindowsAppSDK.WinUI` | **Skip** | Pure XAML composables — `Button`, `Page`, `Application` etc. dynwinrt has no way to host. | -| `Microsoft.Web.WebView2` | **Skip** | Pulled in transitively by WinAppSDK (for the XAML `` control). The whole surface is HWND / Composition-hosted browser embedding — useless from a headless Node / Electron JS context (Electron already renders via Chromium). | -| `Microsoft.WindowsAppSDK.InteractiveExperiences` | **Ref-only** | Ships `Microsoft.UI.WindowId`, `Microsoft.Graphics.PointInt32`, `Microsoft.UI.Color` and other primitive types widely referenced by Foundation/Storage/Notifications APIs — must stay loaded for type resolution, but its own runtime classes are XAML/Composition types winapp cannot drive. | -| Everything else | **Emit** | Bulk-generate JS bindings (codegen still auto-classifies `Windows.*` as refs internally). | - -This split happens in the npm wrapper's `winmd-policy.ts` (`partitionByPackageCategory`). Skipped winmds aren't passed to the codegen at all; ref-only winmds flow through the codegen `--ref` channel. - -**Escape hatch**: if you need the contents of a Skip/Ref-only package (vendor fork, experimentation), list its winmd files explicitly under `winapp.jsBindings.additionalWinmds` — those flow through the user-additional channel and bypass the policy above. - -### The `.dynwinrt-managed` marker and `winmds.lock.json` - -After a successful generation winapp writes `/.dynwinrt-managed` into the output directory. Its presence is the **only** signal winapp uses to know that a non-empty output directory is safe to wipe before the next codegen run. **Never delete this file by hand**, and never put files you care about under the codegen output directory — the next `restore` will wipe anything other than the marker. (If the directory is non-empty and the marker is missing, winapp aborts rather than risk overwriting hand-written code.) - -In addition, `winapp restore` writes `.winapp/winmds.lock.json` — a human-readable snapshot of every NuGet package the restore resolved, with its version and the per-package winmd discovery results. The lockfile is the bridge between the native `winapp restore` (which writes it) and the npm wrapper (which reads it and applies the emit/refOnly/skip policy at codegen time). It's also a useful diagnostic artifact: - -- Useful to share when reporting bugs: it tells the maintainer exactly what got resolved without us needing to repro your NuGet feed setup. -- Records a SHA-256 hash over sorted `lower(name)|version` lines from your yaml `packages:` block — a canonical fingerprint of the resolved set, so the npm wrapper can spot yaml drift between restore runs. - -**Write atomicity**: lockfile writes go through a per-call `.tmp-` sibling that's renamed into place on completion, so concurrent readers always see a consistent (old or new) file, never a torn write. - -## Next steps - -- **CLI** — [`npx winapp init` reference](../../usage.md#init) and [`npx winapp restore` reference](../../usage.md#restore). -- **Runtime** — [`@microsoft/dynwinrt` on GitHub](https://github.com/microsoft/dynwinrt) — the runtime that powers the generated bindings. -- **Codegen** — [`@microsoft/dynwinrt-codegen` on GitHub](https://github.com/microsoft/dynwinrt) — the code-generation tool (same repo as `dynwinrt`). -- **Package & ship** — [Packaging Your App](packaging.md) once you're ready to produce an MSIX for distribution. diff --git a/docs/guides/electron/js-file-picker.md b/docs/guides/electron/js-file-picker.md new file mode 100644 index 00000000..8f0e86a8 --- /dev/null +++ b/docs/guides/electron/js-file-picker.md @@ -0,0 +1,194 @@ + +# Call a Windows File Picker from JavaScript (JS bindings) + +This guide shows you how to call a Windows Runtime (WinRT) API — the native Windows file picker — directly from your Electron app's JavaScript, with no native addon and no `node-gyp` / MSBuild step. It's a great starting point for calling any `Windows.*` or `Microsoft.WindowsAppSDK.*` API from JS/TS with full IntelliSense. + +## Prerequisites + +Before starting this guide, make sure you've: +- Completed the [development environment setup](setup.md). + +## Step 1: Confirm your bindings + +Setup generated a `.winapp/bindings/` directory next to your sources — one `.js` + `.d.ts` pair per WinRT class, plus an `index.js` that re-exports them all: + +``` +.winapp/bindings/ +├── index.js # entry — re-exports every emitted class +├── index.d.ts # TS bundle +├── FileOpenPicker.js # one pair of files per emitted class +├── FileOpenPicker.d.ts +├── PickerLocationId.js +├── PickerLocationId.d.ts +└── … +``` + +## Step 2: Call a WinRT API from your Electron code + +Load classes from the generated `index.js` — you don't need to know which file inside `.winapp/bindings/` a class lives in. Here's a native file picker (`Microsoft.Windows.Storage.Pickers.FileOpenPicker`) opened from your Electron main process. This API works on any Windows 11 machine once you've wired up debug identity in [Step 3](#step-3-run-it): + +```js +// src/index.js (Electron main, CommonJS) +const { app, BrowserWindow, ipcMain } = require('electron'); +const { + FileOpenPicker, + PickerLocationId, + PickerViewMode, +} = require('../.winapp/bindings/index.js'); + +async function pickAnImage(mainWindow) { + // FileOpenPicker needs the parent window's HWND wrapped in a WindowId struct. + // Electron's getNativeWindowHandle() returns an 8-byte buffer on 64-bit Windows. + const hwnd = mainWindow.getNativeWindowHandle().readBigUInt64LE(0); + + const picker = FileOpenPicker.createInstance({ value: hwnd }); + picker.viewMode = PickerViewMode.Thumbnail; + picker.suggestedStartLocation = PickerLocationId.PicturesLibrary; + picker.fileTypeFilter.replaceAll(['.png', '.jpg', '.jpeg', '.gif']); + + const result = await picker.pickSingleFileAsync(); + return result?.path; // string with the chosen path, or undefined if the user cancelled +} + +// Expose it to the renderer via IPC so a button click can trigger the picker. +ipcMain.handle('pick-image', (event) => { + const win = BrowserWindow.fromWebContents(event.sender); + return pickAnImage(win); +}); +``` + +Then bridge it into the renderer through your preload script: + +```js +// src/preload.js +const { contextBridge, ipcRenderer } = require('electron'); + +contextBridge.exposeInMainWorld('winapp', { + pickImage: () => ipcRenderer.invoke('pick-image'), +}); +``` + +Finally, add a button to your renderer and call `window.winapp.pickImage()` when it's clicked: + +```html + + +

+ + +``` + +> [!NOTE] +> These examples are CommonJS (`require`). In an ESM project (`"type": "module"` or TypeScript), use a top-level `import` instead: +> ```js +> import { FileOpenPicker, PickerLocationId, PickerViewMode } from '../.winapp/bindings/index.js'; +> ``` + +> [!IMPORTANT] +> Using **Vite**? Externalize `@microsoft/dynwinrt` in `vite.main.config.mjs`: +> ```js +> import { defineConfig } from 'vite'; +> +> export default defineConfig({ +> build: { +> rollupOptions: { +> external: ['@microsoft/dynwinrt'], +> }, +> }, +> }); +> ``` + +A few conventions the example shows: + +- **Members are camelCase** (`ViewMode` → `viewMode`); names colliding with JS keywords get a trailing underscore (`default_`, `delete_`). +- **Construct via static factories, not `new`** — `FileOpenPicker.createInstance(windowId)`. +- **`UInt64` / `Int64` are `bigint`** — use `buffer.readBigUInt64LE(0)` for raw handles, and pass struct wrappers literally (`{ value: hwnd }`). +- **Async methods return a `Promise`**, and collections expose helpers like `replaceAll(...)`, `toArray()`, `for…of`, and `.size`. + +## Step 3: Run it + +Before the file picker will work, you need to ensure your app runs with identity. Run: + +```bash +npx winapp node add-electron-debug-identity +``` + +> [!NOTE] +> This command is already part of the `postinstall` script we added in the setup guide, so it runs automatically after `npm install`. However, you need to run it manually whenever you modify `Package.appxmanifest`, update app assets, or reinstall dependencies. + +Now start the app: + +```bash +npm start +``` + +Click the button and the native Windows file picker appears! 🎉 Importing from `.winapp/bindings/` loads `@microsoft/dynwinrt`, which dispatches each call into the underlying WinRT API — transparent to your code. + +## Step 4 (optional): Regenerate after a metadata change + +The generated `.winapp/bindings/` files are build artifacts — `.winapp/` is added to `.gitignore` by `init`, so you regenerate them rather than committing. Re-run codegen whenever you change `winapp.yaml` (`packages`, `sdkVersion`, …) or `winapp.jsBindings` in `package.json`: + +```bash +# Full restore — refreshes the lockfile (NuGet + cppwinrt headers) and re-runs codegen. +# Use whenever you change `winapp.yaml`. +npx winapp restore + +# Fast path — only re-runs dynwinrt-codegen against the cached lockfile. +# Use after editing only `winapp.jsBindings`. +npx winapp node generate-bindings +``` + +> [!WARNING] +> Treat the output directory (`.winapp/bindings/`) as fully managed by `winapp` — never put hand-written files there. Each regeneration wipes the directory, keeping only the `.dynwinrt-managed` marker `winapp` uses to recognize it as safe to overwrite. + +## Customizing the binding scope (optional) + +By default — the empty `"jsBindings": {}` block that `init` adds — `winapp` generates bindings for the WinAppSDK packages in your `winapp.yaml`, minus a few that can't be driven from a headless Node process (XAML/WinUI and WebView2 are excluded by default). To narrow or extend that, configure the `winapp.jsBindings` namespace in `package.json` (the schema lives in `package.json`, not `winapp.yaml`, the same convention used by `eslint`, `jest`, `prettier`, …): + +```jsonc +// package.json +{ + "winapp": { + "jsBindings": { + // Extra .winmd files to generate bindings from. Each entry is one of: + // { "winmdPath": "..." } emit the whole winmd + // { "winmdPath": "...", "namespace": "...", "classes": [...] } cherry-pick from it + // { "namespace": "Windows.Storage", "classes": [...] } cherry-pick from the Windows SDK + // Paths are relative to the workspace root, or absolute. + "additionalWinmds": [ + { "winmdPath": "vendor/MyCompany.Foo.winmd" }, + { "namespace": "Windows.Storage", "classes": ["StorageFile"] } + ], + + // Extra .winmd files loaded for type resolution only — never emitted. + "additionalRefs": ["vendor/BigVendor.Common.winmd"] + } + } +} +``` + +Re-run `npx winapp node generate-bindings` after editing the block. XAML namespaces (`Microsoft.UI.Xaml.*`, `Windows.UI.Xaml.*`) are always out of scope — the codegen can't host them, so no JS is emitted regardless of which packages are installed. + +## Next Steps + +Congratulations! You're now calling WinRT APIs directly from JavaScript — no native addon, no `node-gyp` build step. 🎉 + +Now you're ready to: +- **[Package Your App for Distribution](packaging.md)** — produce an MSIX you can ship (the `@microsoft/dynwinrt` runtime is already in your `dependencies`). + +Or explore other guides: +- **[Creating a C++ Native Addon](cpp-notification-addon.md)** — for Win32 / pure-COM APIs that have no WinRT projection. +- **[Creating a Phi Silica Addon](phi-silica-addon.md)** — Windows AI APIs from a C# addon. +- **[Getting Started Overview](index.md)** — return to the main guide. + +### Additional Resources + +- **[winapp CLI Documentation](../../usage.md)** — full CLI reference (`init`, `restore`, `node generate-bindings`). +- **[Sample Electron App](../../../samples/electron/)** — complete working example, including JS bindings. +- **[@microsoft/dynwinrt](https://github.com/microsoft/dynwinrt)** — the runtime that powers the generated bindings. +- **[@microsoft/dynwinrt-codegen](https://www.npmjs.com/package/@microsoft/dynwinrt-codegen)** — the code generator. diff --git a/docs/guides/electron/setup.md b/docs/guides/electron/setup.md index e176613b..7513fa79 100644 --- a/docs/guides/electron/setup.md +++ b/docs/guides/electron/setup.md @@ -59,6 +59,7 @@ When prompted: - **Version**: Press Enter to accept 1.0.0.0 - **Entry point**: Press Enter to accept the default (my-windows-app.exe) - **Setup SDKs**: Select "Stable SDKs" +- **Add JS/TypeScript bindings**: Press Enter to accept the default (**Yes**) to generate JS bindings for Windows App SDK APIs ### What Does `winapp init` Do? @@ -79,6 +80,11 @@ This command sets up everything you need for Windows development: 6. **Enables Developer Mode in Windows** - Required for debugging our application +7. **Generates JS bindings** *(npm wrapper only)* - When you opt in, it: + - Writes the `winapp.jsBindings` block to `package.json` + - Generates JS bindings for Windows App SDK APIs into `.winapp/bindings/` + - Adds the `@microsoft/dynwinrt` runtime to your dependencies + > [!NOTE] > The `.winapp/` folder is automatically added to `.gitignore` and should not be checked in to source. @@ -252,6 +258,7 @@ This restores the original Electron executable without the debug identity. Now that your development environment is set up, you're ready to create native addons and call Windows APIs: +- **[Calling WinRT APIs from JavaScript](js-file-picker.md)** - Use the JS bindings generated during setup to call Windows APIs directly — no native addon required - **[Creating a Phi Silica Addon](phi-silica-addon.md)** - Learn how to create a C# addon that calls the Phi Silica AI API - **[Creating a WinML Addon](winml-addon.md)** - Learn how to create a C# addon that uses Windows Machine Learning - **[Packaging for Distribution](packaging.md)** - Create an MSIX package for distribution diff --git a/docs/usage.md b/docs/usage.md index d72cfa3c..a9f672fe 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -36,12 +36,6 @@ winapp init [base-directory] [options] - `--use-defaults`, `--no-prompt` - Do not prompt, and use default of all prompts - `--config-only` - Only handle configuration file operations, skip package installation -**JS/TypeScript bindings (npm wrapper only):** - -When run via `npx winapp` in a Node / Electron project, `init` adds an interactive prompt — answering **Y** (the default) writes a `"winapp.jsBindings"` namespace to `package.json` and runs codegen. - -See the [JS/TypeScript bindings section](#jstypescript-bindings-via-init--restore) below for the full command matrix and the [Electron JS bindings guide](guides/electron/js-bindings.md) for the schema and end-to-end workflow. - **What it does:** - Creates `winapp.yaml` configuration file (only when SDK packages are managed; skipped with `--setup-sdks none`) @@ -51,6 +45,7 @@ See the [JS/TypeScript bindings section](#jstypescript-bindings-via-init--restor - Sets up build tools and enables developer mode - Updates .gitignore to exclude generated files - Stores shareable files in the global cache directory +- Generates JS bindings for Windows App SDK APIs (npm wrapper only; requires SDK setup) **Automatic .NET project detection:** @@ -153,25 +148,6 @@ winapp update --setup-sdks experimental --- -### JS/TypeScript bindings (via `init` / `restore`) - -Declare JS/TS bindings with a `"winapp": { "jsBindings": {...} }` namespace in **`package.json`**. They're generated alongside the workspace lifecycle — no dedicated sub-command. - -| Want to … | Command | -|---|---| -| Bootstrap a fresh workspace with bindings | `npx winapp init` (answer **Y** at the prompt; default is **Y**) | -| Add bindings to an existing workspace | `npx winapp node generate-bindings` (adds a default `winapp.jsBindings` block on first use) | -| Re-run codegen after editing `winapp.jsBindings` | `npx winapp node generate-bindings` — fast, codegen-only | -| Re-run codegen after editing `winapp.yaml` | `npx winapp restore` — also re-resolves NuGet packages | - -Each generation auto-injects `@microsoft/dynwinrt` as a production dependency in your `package.json` so the emitted bindings can `import` it at runtime. - -Bindings are **npm-only** — they require invocation via `npx winapp` (the `@microsoft/winappcli` npm package); the standalone winget CLI does not surface them. - -> See [JS bindings guide](guides/electron/js-bindings.md) for the full `winapp.jsBindings` schema, the built-in per-package winmd categorization (skip / refOnly / emit), and the `winmds.lock.json` audit artifact. - ---- - ### pack Create MSIX packages from prepared application directories. Requires a manifest file (`Package.appxmanifest` preferred, `appxmanifest.xml` also supported) to be present in the target directory, in the current directory, or passed with the `--manifest` option. (run `init` or `manifest generate` to create a manifest) @@ -829,6 +805,42 @@ winapp get-winapp-path [options] --- +### node generate-bindings + +*(Available in NPM package only)* Generate JS bindings for Windows App SDK APIs. The bindings are declared by a `"winapp": { "jsBindings": {...} }` namespace in **`package.json`** and written to `.winapp/bindings/`. + +```bash +npx winapp node generate-bindings [options] +``` + +**Options:** + +- `--verbose`, `-v` - Enable verbose per-file codegen output +- `--quiet`, `-q` - Suppress progress and informational output + +**What it does:** + +- Reads the `winapp.jsBindings` block from `package.json` and the `winmds.lock.json` written by the last `winapp restore`, then emits typed `.js` + `.d.ts` bindings into `.winapp/bindings/` +- Does **not** modify `package.json` — it is a passive regenerator. Adding the `winapp.jsBindings` block and the `@microsoft/dynwinrt` runtime dependency is [`winapp init`](#init)'s job; this command fails fast if the block is absent +- Warns (but does not write) if `@microsoft/dynwinrt` is missing from your dependencies — run `npm install` after `init` has added it + +> [!NOTE] +> Bindings are **npm-only** — they require invocation via `npx winapp` (the `@microsoft/winappcli` npm package); the standalone winget CLI does not surface them. Run [`winapp init`](#init) first to opt into bindings (it writes the `winapp.jsBindings` block and the `@microsoft/dynwinrt` dependency); this command only regenerates them afterwards. If you edit `winapp.yaml`, run `npx winapp restore` to refresh Windows dependencies before regenerating. + +**Examples:** + +```bash +# Regenerate JS bindings in the current project +npx winapp node generate-bindings + +# Regenerate after editing winapp.jsBindings, with verbose output +npx winapp node generate-bindings --verbose +``` + +> See the [JS bindings guide](guides/electron/js-file-picker.md) for the end-to-end workflow and the `winapp.jsBindings` configuration options. + +--- + ### node create-addon *(Available in NPM package only)* Generate native C++ or C# addon templates with Windows SDK and Windows App SDK integration. diff --git a/samples/electron/src/index.html b/samples/electron/src/index.html index ad35183f..66b9bc5c 100644 --- a/samples/electron/src/index.html +++ b/samples/electron/src/index.html @@ -10,6 +10,7 @@

💖 Hello World!

Welcome to your Electron application.

+ diff --git a/samples/electron/src/index.js b/samples/electron/src/index.js index c733c8fb..78b7155b 100644 --- a/samples/electron/src/index.js +++ b/samples/electron/src/index.js @@ -3,6 +3,15 @@ const path = require('node:path'); const addon = require('../addon/build/Release/addon.node'); +// Typed WinRT JS bindings generated by `winapp init` / `winapp node generate-bindings`. +// These are CommonJS-compatible: recent Node and Electron let you require() the +// generated ESM directly. +const { + FileOpenPicker, + PickerLocationId, + PickerViewMode, +} = require('../.winapp/bindings/index.js'); + let csAddon = undefined; function getCsAddon() { @@ -63,3 +72,19 @@ ipcMain.handle('show-notification', async (event, title, body) => { ipcMain.handle('get-windows-app-runtime-version', async () => { return getCsAddon().Addon.getWindowsAppRuntimeVersion(); }); + +// Calls a WinRT API directly through the generated JS bindings — no native addon required. +ipcMain.handle('pick-image', async (event) => { + const win = BrowserWindow.fromWebContents(event.sender); + + // FileOpenPicker needs the parent window's HWND wrapped in a WindowId struct. + const hwnd = win.getNativeWindowHandle().readBigUInt64LE(0); + + const picker = FileOpenPicker.createInstance({ value: hwnd }); + picker.viewMode = PickerViewMode.Thumbnail; + picker.suggestedStartLocation = PickerLocationId.PicturesLibrary; + picker.fileTypeFilter.replaceAll(['.png', '.jpg', '.jpeg', '.gif']); + + const result = await picker.pickSingleFileAsync(); + return result?.path; +}); diff --git a/samples/electron/src/preload.js b/samples/electron/src/preload.js index b22c6693..97661d9c 100644 --- a/samples/electron/src/preload.js +++ b/samples/electron/src/preload.js @@ -3,4 +3,5 @@ const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('electronAPI', { showNotification: (title, body) => ipcRenderer.invoke('show-notification', title, body), getWindowsAppRuntimeVersion: () => ipcRenderer.invoke('get-windows-app-runtime-version'), + pickImage: () => ipcRenderer.invoke('pick-image'), }); \ No newline at end of file diff --git a/samples/electron/test.Tests.ps1 b/samples/electron/test.Tests.ps1 index 77bd58e5..f0c1cec0 100644 --- a/samples/electron/test.Tests.ps1 +++ b/samples/electron/test.Tests.ps1 @@ -125,8 +125,8 @@ Describe "Electron Sample" { # ── JS bindings smoke (v2.x) ───────────────────────────────────── - It "Should have generated bindings/ with the managed marker" -Skip:$script:skip { - $bindingsDir = Join-Path $script:appDir "bindings" + It "Should have generated .winapp/bindings with the managed marker" -Skip:$script:skip { + $bindingsDir = Join-Path $script:appDir ".winapp\bindings" $bindingsDir | Should -Exist (Join-Path $bindingsDir ".dynwinrt-managed") | Should -Exist # 50 is a generous lower bound for the full WinAppSDK scope; catches @@ -153,7 +153,7 @@ Describe "Electron Sample" { It "Should re-run codegen via 'winapp restore' without mutating winapp.yaml or jsBindings" -Skip:$script:skip { $yamlPath = Join-Path $script:appDir "winapp.yaml" $pkgPath = Join-Path $script:appDir "package.json" - $bindingsDir = Join-Path $script:appDir "bindings" + $bindingsDir = Join-Path $script:appDir ".winapp\bindings" $yamlHashBefore = (Get-FileHash -Path $yamlPath -Algorithm SHA256).Hash $pkgHashBefore = (Get-FileHash -Path $pkgPath -Algorithm SHA256).Hash @@ -171,8 +171,8 @@ Describe "Electron Sample" { } It "Should regenerate bindings via 'winapp node generate-bindings' (codegen-only path)" -Skip:$script:skip { - $bindingsDir = Join-Path $script:appDir "bindings" - # Wipe bindings/ to prove generate-bindings re-creates from the cached lockfile. + $bindingsDir = Join-Path $script:appDir ".winapp\bindings" + # Wipe bindings to prove generate-bindings re-creates from the cached lockfile. if (Test-Path $bindingsDir) { Remove-Item -Recurse -Force $bindingsDir } @@ -340,5 +340,23 @@ Describe "Electron Sample" { $LASTEXITCODE | Should -Be 0 } finally { Pop-Location } } + + # The full JS-bindings pipeline (init → codegen → restore → generate-bindings) + # is exercised end-to-end in Phase 1. Phase 2 only smoke-checks the committed + # sample, so assert its JS-bindings wiring isn't silently dropped. + It "Should declare winapp.jsBindings in the committed sample" -Skip:$script:skip { + $pkgPath = Join-Path $script:sampleDir 'package.json' + $pkg = Get-Content $pkgPath -Raw | ConvertFrom-Json + $pkg.winapp | Should -Not -BeNullOrEmpty -Because "sample must declare a winapp namespace" + $pkg.winapp.PSObject.Properties.Name | Should -Contain 'jsBindings' ` + -Because "sample opts into JS bindings" + } + + It "Should wire 'winapp restore' into the sample postinstall" -Skip:$script:skip { + $pkgPath = Join-Path $script:sampleDir 'package.json' + $pkg = Get-Content $pkgPath -Raw | ConvertFrom-Json + $pkg.scripts.postinstall | Should -Match 'winapp restore' ` + -Because "JS bindings are (re)generated by 'winapp restore' on install" + } } } diff --git a/scripts/build-cli.ps1 b/scripts/build-cli.ps1 index b9faf71b..a443cc7c 100644 --- a/scripts/build-cli.ps1 +++ b/scripts/build-cli.ps1 @@ -233,6 +233,25 @@ try Write-Warning "Node CLI compile failed, Node E2E tests will be skipped" } else { Write-Host "[BUILD] Node CLI built successfully" -ForegroundColor Green + + # Run npm-side TypeScript unit tests (pure-logic jsbindings modules + # + CLI arg parser). Gated on -not $SkipTests like the C# suite. + if (-not $SkipTests) { + Write-Host "[TEST] Running npm unit tests..." -ForegroundColor Blue + npm test + if ($LASTEXITCODE -ne 0) { + Write-Warning "npm unit tests failed with exit code $LASTEXITCODE" + if ($FailOnTestFailure) { + Pop-Location + Write-Error "Stopping build due to npm unit test failures (FailOnTestFailure flag set)" + exit 1 + } else { + Write-Host "[TEST] Continuing build despite npm unit test failures..." -ForegroundColor Yellow + } + } else { + Write-Host "[TEST] npm unit tests passed!" -ForegroundColor Green + } + } } } } finally { diff --git a/scripts/generate-llm-docs.ps1 b/scripts/generate-llm-docs.ps1 index eb87c8f3..f9c30473 100644 --- a/scripts/generate-llm-docs.ps1 +++ b/scripts/generate-llm-docs.ps1 @@ -247,7 +247,7 @@ $SkillDescriptions = @{ "signing" = "Create and manage code signing certificates for Windows apps and MSIX packages. Use when generating a certificate, signing a Windows app or installer, or fixing certificate trust issues." "manifest" = "Create and edit Windows app manifest files (Package.appxmanifest or appxmanifest.xml) that define app identity, capabilities, and visual assets, or generate new assets from existing images. Use when creating a Windows app manifest for any app type (GUI, console, CLI tool, service), adding Windows capabilities, generating new app icons and assets, or adding execution aliases, file associations, protocol handlers, or other app extensions." "troubleshoot" = "Diagnose and fix common Windows app packaging, signing, identity, and SDK errors. Use when encountering errors with MSIX packaging, certificate signing, Windows SDK setup, or app installation." - "frameworks" = "Framework-specific Windows development guidance for Electron, .NET (WPF, WinForms), C++, Rust, Flutter, and Tauri. Use when packaging or adding Windows features to an Electron app, .NET desktop app, Flutter app, Tauri app, Rust app, or C++ app." + "frameworks" = "Framework-specific Windows development guidance for Electron, .NET (WPF, WinForms), C++, Rust, Flutter, and Tauri, including JS bindings for WinRT APIs and native addons. Use when packaging or adding Windows features to an Electron app, .NET desktop app, Flutter app, Tauri app, Rust app, or C++ app." "ui-automation" = "Inspect and interact with running Windows app UIs from the command line using UI Automation (UIA). Use when an AI agent or developer needs to inspect a UI element tree, find controls, take screenshots, click buttons, read or set text, or verify UI state in a running Windows app. Works with any framework WinUI 3, WPF, WinForms, Win32, Electron." } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/WinmdsLockfileServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/WinmdsLockfileServiceTests.cs index 1a681db8..0b7997db 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/WinmdsLockfileServiceTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/WinmdsLockfileServiceTests.cs @@ -40,7 +40,8 @@ public void GetLockfilePath_LandsUnderWinappDir() public async Task WriteAsync_ProducesIndentedSchemaVersionedJson() { var winapp = _temp.CreateSubdirectory("winapp"); - // ExtractPackageIdFromPath requires the literal "packages" segment. + // PackageLayoutService.TryGetPackageIdFromPath keys off the cache root, so the + // winmd must live under a `///...` layout. var cache = _temp.CreateSubdirectory("packages"); var winmd = new FileInfo(Path.Combine( cache.FullName, "microsoft.windowsappsdk.ai", "1.8.39", "metadata", "Microsoft.Windows.AI.winmd")); diff --git a/src/winapp-CLI/WinApp.Cli/Services/PackageLayoutService.cs b/src/winapp-CLI/WinApp.Cli/Services/PackageLayoutService.cs index 4acded59..5177ebc1 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/PackageLayoutService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/PackageLayoutService.cs @@ -16,6 +16,27 @@ private static DirectoryInfo GetPackageDir(DirectoryInfo nugetCacheDir, string p return new DirectoryInfo(Path.Combine(nugetCacheDir.FullName, packageName.ToLowerInvariant(), version)); } + /// + /// Reverse of : given a full path under the NuGet global + /// cache, returns the lowercased package-id segment from the + /// <cache>/<id-lc>/<version>/... layout, or null when the + /// path is outside the cache (e.g. user-supplied additionalWinmds). Single owner of the + /// cache layout convention so callers (lockfile writer) don't re-encode it. + /// + public static string? TryGetPackageIdFromPath(DirectoryInfo nugetCacheDir, string fullPath) + { + var normCache = Path.TrimEndingDirectorySeparator(Path.GetFullPath(nugetCacheDir.FullName)); + var normPath = Path.GetFullPath(fullPath); + if (!normPath.StartsWith(normCache + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) + && !normPath.StartsWith(normCache + Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + var rel = normPath.Substring(normCache.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var firstSep = rel.IndexOfAny([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar]); + return firstSep <= 0 ? null : rel.Substring(0, firstSep).ToLowerInvariant(); + } + /// /// Enumerates all existing package directories from the usedVersions dictionary. /// diff --git a/src/winapp-CLI/WinApp.Cli/Services/WinmdsLockfileService.cs b/src/winapp-CLI/WinApp.Cli/Services/WinmdsLockfileService.cs index c36f0d7b..bb62c764 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/WinmdsLockfileService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/WinmdsLockfileService.cs @@ -135,7 +135,7 @@ internal static WinmdsLockfile BuildLockfile( var winmdsByPackage = new Dictionary>(StringComparer.OrdinalIgnoreCase); foreach (var w in discoveredWinmds) { - var pkgIdLc = ExtractPackageIdFromPath(w.FullName, nugetCacheDir.FullName); + var pkgIdLc = PackageLayoutService.TryGetPackageIdFromPath(nugetCacheDir, w.FullName); if (pkgIdLc is null) { continue; @@ -175,20 +175,4 @@ internal static WinmdsLockfile BuildLockfile( Packages = packages, }; } - - // NuGet cache layout: `///...`. - // Returns null for user-supplied additionalWinmds outside the cache. - private static string? ExtractPackageIdFromPath(string winmdFullPath, string nugetCacheDir) - { - var normCache = Path.TrimEndingDirectorySeparator(Path.GetFullPath(nugetCacheDir)); - var normWinmd = Path.GetFullPath(winmdFullPath); - if (!normWinmd.StartsWith(normCache + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) - && !normWinmd.StartsWith(normCache + Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) - { - return null; - } - var rel = normWinmd.Substring(normCache.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - var firstSep = rel.IndexOfAny(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }); - return firstSep <= 0 ? null : rel.Substring(0, firstSep).ToLowerInvariant(); - } } diff --git a/src/winapp-npm/package-lock.json b/src/winapp-npm/package-lock.json index 8848f428..ce1b011c 100644 --- a/src/winapp-npm/package-lock.json +++ b/src/winapp-npm/package-lock.json @@ -13,7 +13,7 @@ "win32" ], "dependencies": { - "@microsoft/dynwinrt-codegen": "0.1.0-preview.2" + "@microsoft/dynwinrt-codegen": "0.1.0-preview.5" }, "bin": { "winapp": "dist/cli.js" @@ -200,9 +200,9 @@ } }, "node_modules/@microsoft/dynwinrt-codegen": { - "version": "0.1.0-preview.2", - "resolved": "https://registry.npmjs.org/@microsoft/dynwinrt-codegen/-/dynwinrt-codegen-0.1.0-preview.2.tgz", - "integrity": "sha512-pIJwYbFug4QUIcnbTL3z2wUZh0qdWtYODTH0H8GjZ3Neku6DgwH6IMo+pXx0drimAtAaXR05ttB1AALidIN7+w==", + "version": "0.1.0-preview.5", + "resolved": "https://registry.npmjs.org/@microsoft/dynwinrt-codegen/-/dynwinrt-codegen-0.1.0-preview.5.tgz", + "integrity": "sha512-nTa4d7poFgR68GLZNLEAmEQ5M++otLbsWogHNlrSdctEyhwYn+LqM4wO0P4btKltbXJHeP30otZHFJXi1r4cHA==", "license": "MIT", "os": [ "win32" diff --git a/src/winapp-npm/package.json b/src/winapp-npm/package.json index c05ae1f6..3edf1816 100644 --- a/src/winapp-npm/package.json +++ b/src/winapp-npm/package.json @@ -14,6 +14,7 @@ "generate-docs:check": "node scripts/generate-docs.mjs --check", "compile": "tsc", "compile:watch": "tsc --watch", + "test": "tsc -p tsconfig.test.json && node --test \"dist-test/test/**/*.test.js\"", "lint": "eslint src/", "lint:fix": "eslint src/ --fix", "format": "prettier --write src/", @@ -52,7 +53,7 @@ "win32" ], "dependencies": { - "@microsoft/dynwinrt-codegen": "0.1.0-preview.2" + "@microsoft/dynwinrt-codegen": "0.1.0-preview.5" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/src/winapp-npm/src/cli-args.ts b/src/winapp-npm/src/cli-args.ts index b9d6ec56..5407770e 100644 --- a/src/winapp-npm/src/cli-args.ts +++ b/src/winapp-npm/src/cli-args.ts @@ -69,6 +69,11 @@ export function hasConfigOnly(args: readonly string[]): boolean { return args.includes('--config-only'); } +/** Detect `--no-install` — opt out of auto-installing the runtime dependency. */ +export function hasNoInstall(args: readonly string[]): boolean { + return args.includes('--no-install'); +} + /** Detect `--use-defaults` / `--no-prompt` / `-y` / `--yes`. */ export function hasUseDefaults(args: readonly string[]): boolean { return args.some((a) => USE_DEFAULTS_FLAGS.has(a)); diff --git a/src/winapp-npm/src/cli.ts b/src/winapp-npm/src/cli.ts index 778f4afc..c381b811 100644 --- a/src/winapp-npm/src/cli.ts +++ b/src/winapp-npm/src/cli.ts @@ -8,7 +8,7 @@ import { askBindingsKind, parseSetupSdksArg } from './jsbindings/init-prompt'; import { hasJsBindings, ensureJsBindingsBlock } from './jsbindings/package-json-config'; import { runJsBindingsPipeline } from './jsbindings/orchestrator'; import { getLockfilePath, LOCKFILE_NAME } from './jsbindings/lockfile-reader'; -import { resolveWorkspaceDir, resolveYamlPath, isVerbose, isQuiet, hasConfigOnly } from './cli-args'; +import { resolveWorkspaceDir, resolveYamlPath, isVerbose, isQuiet, hasConfigOnly, hasNoInstall } from './cli-args'; import { assertSafeWorkspaceFile } from './jsbindings/path-safety'; import { spawn } from 'child_process'; import * as fs from 'fs'; @@ -564,6 +564,11 @@ async function handleClearElectronDebugIdentity(args: string[]): Promise { * dynwinrt-codegen. Intended for fast iteration after editing the `winapp.jsBindings` * block (packages scope, extraTypes, skip/refOnly/emit overrides). * + * Passive by design: it only *reads* these two inputs and emits bindings — it + * never writes package.json. Adding the `winapp.jsBindings` block and the + * `@microsoft/dynwinrt` dependency is `winapp init`'s job; this command fails + * fast when the block is absent. + * * Pre-checks fail fast with an actionable hint when a prerequisite is missing. */ async function handleGenerateBindings(args: string[]): Promise { @@ -577,12 +582,14 @@ async function handleGenerateBindings(args: string[]): Promise { console.log('Regenerate JS/TypeScript bindings from package.json + cached winmds'); console.log(''); console.log('This command will:'); - console.log(' 1. Read (or add a default) `winapp.jsBindings` block in package.json'); + console.log(' 1. Read the `winapp.jsBindings` block from package.json'); console.log(' 2. Read the cached winmd inventory from .winapp/winmds.lock.json'); - console.log(' 3. Run dynwinrt-codegen into the configured output directory'); - console.log(' 4. Ensure @microsoft/dynwinrt is listed in package.json dependencies'); + console.log(' 3. Run dynwinrt-codegen into the output directory'); console.log(''); - console.log('It does NOT re-run the native restore. If you have never run'); + console.log('It only reads `winapp.jsBindings` + the cached lockfile and emits bindings —'); + console.log('it does NOT modify package.json. Run `winapp init` to opt into JS bindings'); + console.log('(it adds the `winapp.jsBindings` block and the @microsoft/dynwinrt dependency).'); + console.log('It also does NOT re-run the native restore. If you have never run'); console.log('`winapp restore` in this workspace (so there is no winmd lockfile yet)'); console.log('or you changed `winapp.yaml` since the last restore, run `winapp restore`'); console.log('first, then re-run this command.'); @@ -610,13 +617,17 @@ async function handleGenerateBindings(args: string[]): Promise { process.exit(1); } - // 2. Make sure the `winapp.jsBindings` namespace exists. Running this - // command is itself a strong signal that the user wants bindings — - // refusing on a missing block would force a hand-edit of package.json - // for no real safety benefit. `restore` deliberately does NOT call - // this helper: it stays passive and only acts when the user has - // already declared bindings. - ensureJsBindingsBlock(workspaceDir, { quiet }); + // 2. The `winapp.jsBindings` namespace must already exist. This command is a + // passive regenerator: it only reads `winapp.jsBindings` + the cached + // lockfile and emits bindings — it never writes declarations. Adding the + // block (and the runtime dependency) is `winapp init`'s job, so fail fast + // with an actionable hint instead of silently creating it here. + if (!hasJsBindings(workspaceDir)) { + console.error('❌ No "winapp.jsBindings" namespace in package.json.'); + console.error(' Run `winapp init` to opt into JS bindings (it adds the block and the'); + console.error(' @microsoft/dynwinrt dependency), then re-run this command to regenerate.'); + process.exit(1); + } // 3. Lockfile from a prior `winapp restore` must be present. (Schema-mismatch // cases get the orchestrator's more detailed message via `lockfileStale`.) @@ -648,11 +659,33 @@ async function handleInit(args: string[]): Promise { const workspaceDir = resolveWorkspaceDir(args); const quiet = isQuiet(args); const configOnly = hasConfigOnly(args); + const noInstall = hasNoInstall(args); // Re-running init on a configured workspace? Infer the choice rather than // re-prompt so we never silently drop the user's prior customizations. const existingJsBindings = hasJsBindings(workspaceDir); + // Native init runs FIRST so all of its own prompts (package name, publisher, + // version, SDK setup, …) complete before we ask about JS bindings. Asking + // last also lets us gate the question on whether SDK setup actually happened. + // Native init runs with the user's literal argv (no flag injection), minus + // wrapper-only flags the native CLI doesn't recognize (`--no-install`). We + // deliberately do NOT change the child's cwd: native `init` resolves + // `base-directory` / `--config-dir` against its own cwd, so changing it would + // double-resolve any relative path (`winapp init subdir` → spawn + // cwd=/foo/subdir + arg `subdir` → native lands in /foo/subdir/subdir). + // Wrapper-side `workspaceDir` is an absolute path we only use for our OWN + // bookkeeping (package.json read/write, codegen output, prompts). + const nativeArgs = args.filter((a) => a !== '--no-install'); + await callWinappCli(['init', ...nativeArgs], { exitOnError: true }); + + // SDK setup writes .winapp/winmds.lock.json during winmd discovery (Step 5), + // so its presence tells us JS bindings have winmds to generate against. When + // the user declines SDK setup there's no lockfile — and nothing to bind to. + // `--config-only` skips package install (no lockfile) but defers codegen on + // purpose, so treat it as "ready" for the opt-in decision below. + const lockfilePresent = fs.existsSync(getLockfilePath(workspaceDir)); + let outcome; try { outcome = await askBindingsKind({ @@ -660,6 +693,7 @@ async function handleInit(args: string[]): Promise { argv: args, isInit: true, existingJsBindings, + sdksReady: lockfilePresent || configOnly, }); } catch (err) { logErrorAndExit(err); @@ -669,16 +703,6 @@ async function handleInit(args: string[]): Promise { console.log(`ℹ️ ${outcome.silentReason}`); } - // Native init runs with the user's literal argv (no flag injection, no - // JS-bindings-aware overrides). We deliberately do NOT change the child's - // cwd: native `init` resolves `base-directory` / `--config-dir` against - // its own cwd, so changing it would double-resolve any relative path - // (`winapp init subdir` → spawn cwd=/foo/subdir + arg `subdir` - // → native lands in /foo/subdir/subdir). Wrapper-side `workspaceDir` is - // an absolute path we only use for our OWN bookkeeping (package.json - // read/write, codegen output, prompts). - await callWinappCli(['init', ...args], { exitOnError: true }); - // User opted out — nothing more to do. if (outcome.kind === 'no') { return; @@ -727,7 +751,24 @@ async function handleInit(args: string[]): Promise { // implementation re-ran `winapp restore` here defensively, but that doubled // the cost of init and ignored `--config-only`. Hand straight off to the // orchestrator instead. - await runJsBindingsOrchestrator(workspaceDir, isVerbose(args), quiet, resolveYamlPath(args)); + // + // Guard: an init re-run can infer Yes from existing config even though SDK + // setup was skipped this time (no lockfile). Generating against a missing + // winmd inventory would fail; inform and defer instead of erroring out. + if (!lockfilePresent) { + if (!quiet) { + console.log( + 'ℹ️ Windows SDKs were not set up, so JS bindings were not generated. ' + + 'Run `npx winapp restore` then `npx winapp node generate-bindings` to generate them.' + ); + } + return; + } + + // init is the onboarding flow: it is the only command that *writes* the + // runtime dependency to package.json (manageRuntimeDep) and, unless the user + // opted out with --no-install, also installs it into node_modules. + await runJsBindingsOrchestrator(workspaceDir, isVerbose(args), quiet, resolveYamlPath(args), !noInstall, true); } /** @@ -746,6 +787,22 @@ async function handleRestore(args: string[]): Promise { if (!hasJsBindings(workspaceDir)) { return; } + + // Native restore is a no-op success when there's no winapp.yaml or it has no + // packages — in those cases it writes no lockfile. Don't regress that contract + // into a hard failure: with no lockfile there's simply nothing to generate, so + // skip with an informational note instead of erroring out (the orchestrator + // would otherwise return `lockfileMissing` → exit 1). + if (!fs.existsSync(getLockfilePath(workspaceDir))) { + if (!quiet) { + console.log( + 'ℹ️ No winmd inventory found (winapp.yaml has no packages yet), so JS bindings ' + + 'were not generated. Add packages to `winapp.yaml` and re-run `npx winapp restore`.' + ); + } + return; + } + await runJsBindingsOrchestrator(workspaceDir, isVerbose(args), quiet, resolveYamlPath(args)); } @@ -754,10 +811,19 @@ async function runJsBindingsOrchestrator( workspaceDir: string, verbose: boolean = false, quiet: boolean = false, - yamlPath?: string + yamlPath?: string, + installRuntimeDep: boolean = false, + manageRuntimeDep: boolean = false ): Promise { try { - const result = await runJsBindingsPipeline({ workspaceDir, verbose, quiet, yamlPath }); + const result = await runJsBindingsPipeline({ + workspaceDir, + verbose, + quiet, + yamlPath, + installRuntimeDep, + manageRuntimeDep, + }); switch (result.outcome) { case 'completed': if (!quiet) { diff --git a/src/winapp-npm/src/cpp-addon-utils.ts b/src/winapp-npm/src/cpp-addon-utils.ts index bfdf3c5a..3d141ebe 100644 --- a/src/winapp-npm/src/cpp-addon-utils.ts +++ b/src/winapp-npm/src/cpp-addon-utils.ts @@ -6,6 +6,7 @@ import { checkAndInstallPython, checkAndInstallVisualStudio as checkAndInstallVisualStudioTools, } from './dependency-utils'; +import { mutatePackageJsonDoc } from './jsbindings/package-json-doc'; export interface GenerateCppAddonOptions { name?: string; @@ -206,31 +207,25 @@ async function installRequiredPackages(projectRoot: string, verbose: boolean): P * @param verbose - Enable verbose logging */ async function addBuildScript(addonName: string, projectRoot: string, verbose: boolean): Promise { - const packageJsonPath = path.join(projectRoot, 'package.json'); - - // Read current package.json - const packageJsonContent = await fs.readFile(packageJsonPath, 'utf8'); - const packageJson = JSON.parse(packageJsonContent); - - // Initialize scripts if it doesn't exist - if (!packageJson.scripts) { - packageJson.scripts = {}; - } - // Find a unique script name let scriptName = `build-${addonName}`; - let counter = 1; - while (packageJson.scripts[scriptName]) { - scriptName = `build-${addonName}${counter}`; - counter++; - } + mutatePackageJsonDoc(projectRoot, (packageJson) => { + const scripts = + packageJson.scripts && typeof packageJson.scripts === 'object' + ? (packageJson.scripts as Record) + : {}; - // Add the build script - packageJson.scripts[scriptName] = `node-gyp clean configure build --directory=${addonName}`; + let counter = 1; + while (scripts[scriptName]) { + scriptName = `build-${addonName}${counter}`; + counter++; + } - // Write back to package.json - await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2), 'utf8'); + // Add the build script + scripts[scriptName] = `node-gyp clean configure build --directory=${addonName}`; + packageJson.scripts = scripts; + }); if (verbose) { console.log(`📝 Added build script: ${scriptName}`); diff --git a/src/winapp-npm/src/cs-addon-utils.ts b/src/winapp-npm/src/cs-addon-utils.ts index 78ec0821..11a90c3b 100644 --- a/src/winapp-npm/src/cs-addon-utils.ts +++ b/src/winapp-npm/src/cs-addon-utils.ts @@ -3,6 +3,7 @@ import * as fsSync from 'fs'; import * as path from 'path'; import { execSync } from 'child_process'; import { checkAndInstallDotnetSdk, checkAndInstallVisualStudio } from './dependency-utils'; +import { mutatePackageJsonDoc } from './jsbindings/package-json-doc'; export interface GenerateCsAddonOptions { name?: string; @@ -321,47 +322,42 @@ async function installNodeApiDotnet(projectRoot: string, verbose: boolean): Prom * @param verbose - Enable verbose logging */ async function addCsBuildScripts(addonName: string, projectRoot: string, verbose: boolean): Promise { - const packageJsonPath = path.join(projectRoot, 'package.json'); - - // Read current package.json - const packageJsonContent = await fs.readFile(packageJsonPath, 'utf8'); - const packageJson = JSON.parse(packageJsonContent); - - // Initialize scripts if it doesn't exist - if (!packageJson.scripts) { - packageJson.scripts = {}; - } - // Add build script - use publish to generate .node file const buildScriptName = `build-${addonName}`; // Use dotnet publish - RuntimeIdentifier is set in the .csproj with a default // Can be overridden with: npm run build-csAddon -- /p:RuntimeIdentifier=win-arm64 const buildCommand = `dotnet publish ./${addonName}/${addonName}.csproj -c Release`; - if (packageJson.scripts[buildScriptName]) { - if (verbose) { - console.log(`⚠️ Build script '${buildScriptName}' already exists, skipping`); - } - } else { - packageJson.scripts[buildScriptName] = buildCommand; - if (verbose) { - console.log(`📝 Added build script: ${buildScriptName}`); - } - } - // Add clean script (without cs- prefix) const cleanScriptName = `clean-${addonName}`; const cleanCommand = `dotnet clean ./${addonName}/${addonName}.csproj`; - if (!packageJson.scripts[cleanScriptName]) { - packageJson.scripts[cleanScriptName] = cleanCommand; - if (verbose) { - console.log(`📝 Added clean script: ${cleanScriptName}`); + mutatePackageJsonDoc(projectRoot, (packageJson) => { + const scripts = + packageJson.scripts && typeof packageJson.scripts === 'object' + ? (packageJson.scripts as Record) + : {}; + + if (scripts[buildScriptName]) { + if (verbose) { + console.log(`⚠️ Build script '${buildScriptName}' already exists, skipping`); + } + } else { + scripts[buildScriptName] = buildCommand; + if (verbose) { + console.log(`📝 Added build script: ${buildScriptName}`); + } + } + + if (!scripts[cleanScriptName]) { + scripts[cleanScriptName] = cleanCommand; + if (verbose) { + console.log(`📝 Added clean script: ${cleanScriptName}`); + } } - } - // Write back to package.json - await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2), 'utf8'); + packageJson.scripts = scripts; + }); } /** diff --git a/src/winapp-npm/src/jsbindings/codegen-runner.ts b/src/winapp-npm/src/jsbindings/codegen-runner.ts index f11eaea3..68c79b8c 100644 --- a/src/winapp-npm/src/jsbindings/codegen-runner.ts +++ b/src/winapp-npm/src/jsbindings/codegen-runner.ts @@ -10,7 +10,7 @@ import * as path from 'path'; import * as os from 'os'; import * as crypto from 'crypto'; import { spawn } from 'child_process'; -import { JsBindingsConfig } from './package-json-config'; +import { JS_BINDINGS_OUTPUT_DIR } from './package-json-config'; import { assertSafeWorkspaceOutputDir, isNetworkPath, hasReparsePointOnPath } from './path-safety'; // Authorises later runs to wipe the generated output dir. @@ -27,7 +27,6 @@ export interface CodegenCherryPick { } export interface CodegenInputs { - config: JsBindingsConfig; /** Winmds that generate bindings after policy filtering. */ emitWinmds: readonly string[]; /** Winmds loaded for type resolution only. */ @@ -58,7 +57,7 @@ export async function runCodegen(inputs: CodegenInputs): Promise const log = inputs.log ?? ((line) => process.stdout.write(line + os.EOL)); const verbose = inputs.verbose ?? false; - const outputDir = resolveOutputDir(inputs.workspaceDir, inputs.config.output); + const outputDir = resolveOutputDir(inputs.workspaceDir); fs.mkdirSync(path.dirname(outputDir), { recursive: true }); const emit = dedupeCaseInsensitive(inputs.emitWinmds); @@ -93,10 +92,9 @@ export async function runCodegen(inputs: CodegenInputs): Promise return { outputDir, summary }; } -export function resolveOutputDir(workspaceDir: string, output: string): string { - // This directory is wiped each run; keep it inside the workspace and reparse-free. - const out = output && output.trim() ? output : 'bindings'; - return assertSafeWorkspaceOutputDir(workspaceDir, out, 'jsBindings.output'); +export function resolveOutputDir(workspaceDir: string): string { + // Fixed output dir; wiped each run, so keep it inside the workspace and reparse-free. + return assertSafeWorkspaceOutputDir(workspaceDir, JS_BINDINGS_OUTPUT_DIR, 'jsBindings output'); } /** Throws when outputDir contains files we didn't generate. Empty / missing OK. */ @@ -153,6 +151,31 @@ function writeManagedMarker(outputDir: string): void { fs.writeFileSync(markerPath, lines.join('\n'), { encoding: 'utf8' }); } +// Generated bindings are ESM. A sub-package.json `{ "type": "module" }` tells Node +// to treat them as such, avoiding the MODULE_TYPELESS_PACKAGE_JSON reparse warning +// (and the perf hit it implies) when they're require()'d. Idempotent: ensures the +// marker even when the pinned dynwinrt-codegen version doesn't emit one itself. +export function ensureEsmPackageMarker(outputDir: string): void { + fs.mkdirSync(outputDir, { recursive: true }); + const pkgPath = path.join(outputDir, 'package.json'); + let pkg: Record = {}; + if (fs.existsSync(pkgPath)) { + try { + const parsed = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + if (parsed && typeof parsed === 'object') { + pkg = parsed as Record; + } + } catch { + // Corrupt/non-JSON: overwrite with the minimal marker below. + } + } + if (pkg.type === 'module') { + return; + } + pkg.type = 'module'; + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', { encoding: 'utf8' }); +} + /** Stage → backup-old → swap → drop-backup. Visible for tests. */ export async function runWithStaging( outputDir: string, @@ -169,6 +192,7 @@ export async function runWithStaging( try { await generate(stagingDir); + ensureEsmPackageMarker(stagingDir); writeManagedMarker(stagingDir); validateOutputDirIsWipeable(outputDir); @@ -251,13 +275,26 @@ export function buildExtraTypeArgs( extra: CodegenCherryPick ): string[] { const args: string[] = [...prefixArgs, 'generate']; - const emitSet = new Set(emitWinmds); + // refWinmds and emitWinmds are disjoint (runCodegen drops emit from refs). + const refSet = new Set(refWinmds); + if (extra.winmdPath) { + // Explicit winmd: emit the cherry-picked class from it, alongside the bulk + // emit set so types declared across those winmds resolve. + const emitSet = new Set(emitWinmds); emitSet.add(extra.winmdPath); - } - if (emitSet.size > 0) { args.push('--winmd', Array.from(emitSet).join(';')); + refSet.delete(extra.winmdPath); + } else { + // Path-less cherry-pick: the user is targeting a class in the SDK's + // auto-detected Windows.winmd. Passing --winmd here would DISABLE that + // auto-detection and hide the Windows.* types the class needs. Omit it and + // expose the NuGet emit winmds via --ref so cross-references still resolve. + for (const w of emitWinmds) { + refSet.add(w); + } } + args.push( '--namespace', extra.namespace, @@ -268,7 +305,8 @@ export function buildExtraTypeArgs( '--lang', 'js' ); - const refs = extra.winmdPath ? refWinmds.filter((r) => r !== extra.winmdPath) : refWinmds; + + const refs = Array.from(refSet); if (refs.length > 0) { args.push('--ref', refs.join(';')); } diff --git a/src/winapp-npm/src/jsbindings/init-prompt.ts b/src/winapp-npm/src/jsbindings/init-prompt.ts index 6198a884..cefc2af5 100644 --- a/src/winapp-npm/src/jsbindings/init-prompt.ts +++ b/src/winapp-npm/src/jsbindings/init-prompt.ts @@ -15,6 +15,12 @@ export interface BindingsPromptInputs { isInit: boolean; /** True when package.json already declares `winapp.jsBindings`. */ existingJsBindings: boolean; + /** + * True when the Windows SDKs were set up (a winmd lockfile exists) or codegen + * is being deliberately deferred (`--config-only`). When false, a fresh opt-in + * is skipped: JS bindings need the Windows App SDK winmds to generate against. + */ + sdksReady: boolean; } export interface BindingsPromptOutcome { @@ -87,6 +93,18 @@ export async function askBindingsKind(inputs: BindingsPromptInputs): Promise USE_DEFAULTS_FLAGS.has(a)); if (useDefaults) { return { kind: 'yes', silentReason: '--use-defaults — opting in to JS bindings.' }; diff --git a/src/winapp-npm/src/jsbindings/orchestrator.ts b/src/winapp-npm/src/jsbindings/orchestrator.ts index abc9d78d..2cd985f1 100644 --- a/src/winapp-npm/src/jsbindings/orchestrator.ts +++ b/src/winapp-npm/src/jsbindings/orchestrator.ts @@ -10,8 +10,9 @@ import { tryReadLockfile } from './lockfile-reader'; import { partitionPackageWinmds } from './winmd-policy'; import { resolveAdditionalWinmds } from './additional-winmds'; import { runCodegen } from './codegen-runner'; -import { ensureRuntimeDependency, formatRuntimeDependencyHint, getDynWinrtVersionPin } from './runtime-dep-injector'; +import { ensureRuntimeDependency, formatRuntimeDependencyHint, getDynWinrtVersionPin, isRuntimeDependencyDeclared } from './runtime-dep-injector'; import { detectPackageManager } from './package-manager-detector'; +import { installRuntimeDependency } from './runtime-installer'; import { startSpinner, Spinner } from './spinner'; import { computeYamlPackagesHash, readWinappYamlPackages } from './yaml-packages-hash'; @@ -39,6 +40,21 @@ export interface OrchestratorOptions { verbose?: boolean; /** Suppress progress and hints; warnings still go through `log`. */ quiet?: boolean; + /** + * When true (the `init` onboarding flow), declare `@microsoft/dynwinrt` in + * package.json — the only place the runtime dependency is *written*. The + * passive flows (`restore` / `node generate-bindings`) leave it false: they + * only read `winapp.jsBindings` + the lockfile and emit bindings, never + * mutating package.json. They warn instead when the dep is missing. + */ + manageRuntimeDep?: boolean; + /** + * When true (the `init` onboarding flow), materialize the runtime dependency + * into node_modules via the detected package manager instead of only writing + * it to package.json. Best-effort: install failures degrade to a warning. + * Only meaningful when `manageRuntimeDep` is also true. + */ + installRuntimeDep?: boolean; } export async function runJsBindingsPipeline(options: OrchestratorOptions): Promise { @@ -162,7 +178,6 @@ export async function runJsBindingsPipeline(options: OrchestratorOptions): Promi let codegenResult; try { codegenResult = await runCodegen({ - config, emitWinmds, refWinmds, cherryPicks, @@ -174,25 +189,68 @@ export async function runJsBindingsPipeline(options: OrchestratorOptions): Promi spinner?.stop(); } - // Runtime dep injection is best-effort; codegen output is still useful if it fails. - const pinnedVersion = options.versionOverride ?? safeGetVersionPin(log); - if (pinnedVersion) { - try { - const ensureResult = ensureRuntimeDependency(workspaceDir, RUNTIME_PACKAGE_NAME, pinnedVersion); - const pm = detectPackageManager(workspaceDir); - const hint = formatRuntimeDependencyHint( - ensureResult.outcome, - RUNTIME_PACKAGE_NAME, - ensureResult.pinnedVersion, - pm.installCommand - ); - if (!options.quiet) { - log(hint.message); + // Runtime dependency handling differs by flow: + // * init (manageRuntimeDep): declare @microsoft/dynwinrt in package.json + // (best-effort; codegen output is still useful if it fails) and, unless + // opted out, install it. + // * restore / generate-bindings (passive): never mutate package.json — just + // warn if the runtime the generated bindings import isn't declared. init + // is responsible for adding it. + if (options.manageRuntimeDep) { + const pinnedVersion = options.versionOverride ?? safeGetVersionPin(log); + if (pinnedVersion) { + try { + const ensureResult = ensureRuntimeDependency(workspaceDir, RUNTIME_PACKAGE_NAME, pinnedVersion); + const pm = detectPackageManager(workspaceDir); + + // The dep is now declared in package.json. On the init onboarding flow, + // also install it so the generated bindings are runnable without a manual + // second step. Only do this when the dep actually landed in `dependencies` + // (added / alreadyPresent) — `noPackageJson` / `presentInDevDependencies` + // have nothing installable and fall through to the manual hint below. + const dependencyDeclared = ensureResult.outcome === 'added' || ensureResult.outcome === 'alreadyPresent'; + if (options.installRuntimeDep && dependencyDeclared) { + const installVersion = ensureResult.pinnedVersion ?? pinnedVersion; + if (!options.quiet) { + log(`📦 Installing ${RUNTIME_PACKAGE_NAME}@${installVersion} with ${pm.name}...`); + } + const install = installRuntimeDependency(workspaceDir, RUNTIME_PACKAGE_NAME, installVersion, pm.name); + if (install.ok) { + if (!options.quiet) { + log(`✅ Installed ${RUNTIME_PACKAGE_NAME}@${installVersion}.`); + } + } else { + // Best-effort: bindings are already generated and the dep is in + // package.json — surface a warning (even in --quiet) so the user can + // finish the install manually. + log( + `⚠️ Could not auto-install ${RUNTIME_PACKAGE_NAME}@${installVersion}: ${install.error}. ` + + `Run \`${pm.installCommand}\` to install it locally.` + ); + } + } else { + const hint = formatRuntimeDependencyHint( + ensureResult.outcome, + RUNTIME_PACKAGE_NAME, + ensureResult.pinnedVersion, + pm.installCommand + ); + if (!options.quiet) { + log(hint.message); + } + } + } catch (err) { + // Warnings always surface, even in --quiet. + log(`⚠️ Failed to ensure runtime dependency: ${(err as Error).message}`); } - } catch (err) { - // Warnings always surface, even in --quiet. - log(`⚠️ Failed to ensure runtime dependency: ${(err as Error).message}`); } + } else if (!isRuntimeDependencyDeclared(workspaceDir, RUNTIME_PACKAGE_NAME)) { + // Passive flow with the runtime missing — bindings would fail to resolve it + // at runtime. Warn (even in --quiet) but don't write: adding is init's job. + log( + `⚠️ ${RUNTIME_PACKAGE_NAME} is not declared in package.json dependencies. ` + + 'Generated bindings import it at runtime — run `winapp init` to add it (or add it manually).' + ); } return { diff --git a/src/winapp-npm/src/jsbindings/package-json-config.ts b/src/winapp-npm/src/jsbindings/package-json-config.ts index 7789d08d..3094b2d3 100644 --- a/src/winapp-npm/src/jsbindings/package-json-config.ts +++ b/src/winapp-npm/src/jsbindings/package-json-config.ts @@ -4,9 +4,11 @@ import { AdditionalWinmd } from './additional-winmds'; import { readPackageJsonDoc, mutatePackageJsonDoc, packageJsonExists } from './package-json-doc'; +// Fixed, non-configurable codegen output directory (relative to the workspace root). +// Mirrors the C++ `.winapp/include` convention and is auto-gitignored by `winapp init`. +export const JS_BINDINGS_OUTPUT_DIR = '.winapp/bindings'; + export interface JsBindingsConfig { - // Output directory, relative to the workspace root. - output: string; // Extra .winmd files to feed into codegen, either bulk-emitted or cherry-picked. additionalWinmds: AdditionalWinmd[]; // Extra .winmd files loaded for type resolution only. @@ -15,7 +17,6 @@ export interface JsBindingsConfig { export function defaultJsBindingsConfig(): JsBindingsConfig { return { - output: 'bindings', additionalWinmds: [], additionalRefs: [], }; @@ -68,7 +69,7 @@ export function ensureJsBindingsBlock( if (!opts.quiet) { console.log( 'ℹ️ Added "winapp.jsBindings" to package.json. ' + - 'Edit `output`, `additionalWinmds`, or `additionalRefs` to customize.' + 'Edit `additionalWinmds` or `additionalRefs` to customize.' ); } return 'added'; @@ -117,7 +118,6 @@ function coerceConfig(raw: unknown): JsBindingsConfig { const r = raw as Record; return { - output: typeof r.output === 'string' && r.output.trim() ? r.output.trim() : defaults.output, additionalWinmds: coerceAdditionalWinmds(r.additionalWinmds), additionalRefs: coerceStringArray(r.additionalRefs), }; @@ -172,7 +172,6 @@ function coerceAdditionalWinmds(value: unknown): AdditionalWinmd[] { /** Stable key order; empty arrays remain explicit defaults in package.json. */ function serializeConfig(config: JsBindingsConfig): Record { return { - output: config.output, additionalWinmds: config.additionalWinmds.map((w) => { const entry: Record = {}; if (w.winmdPath) { diff --git a/src/winapp-npm/src/jsbindings/package-json-doc.ts b/src/winapp-npm/src/jsbindings/package-json-doc.ts index 25170a24..6157f80b 100644 --- a/src/winapp-npm/src/jsbindings/package-json-doc.ts +++ b/src/winapp-npm/src/jsbindings/package-json-doc.ts @@ -10,10 +10,13 @@ // * atomic temp-file + rename, with copy+unlink fallback for cross-volume // edge cases (AV interference, mapped network shares, etc). // -// Consumers (package-json-config.ts, runtime-dep-injector.ts) should NEVER -// open-code `fs.readFileSync(packageJson)` / `JSON.parse` / `fs.renameSync` -// — go through `readPackageJsonDoc` / `mutatePackageJsonDoc` so changes to -// safety policy land in one place. +// Consumers should NEVER open-code `fs.readFileSync(packageJson)` / +// `JSON.parse` / `fs.renameSync`. This is the single chokepoint for EVERY +// wrapper site that reads or mutates the user's package.json — the jsbindings +// config + runtime-dep injection (package-json-config.ts, runtime-dep-injector.ts) +// AND the C#/C++ addon scaffolders (cs-addon-utils.ts, cpp-addon-utils.ts). +// Go through `readPackageJsonDoc` / `mutatePackageJsonDoc` so path-safety, +// EOL preservation, and atomic-write policy all land in one place. import * as fs from 'fs'; import * as path from 'path'; diff --git a/src/winapp-npm/src/jsbindings/package-manager-detector.ts b/src/winapp-npm/src/jsbindings/package-manager-detector.ts index 6c234326..c7aacfae 100644 --- a/src/winapp-npm/src/jsbindings/package-manager-detector.ts +++ b/src/winapp-npm/src/jsbindings/package-manager-detector.ts @@ -14,11 +14,33 @@ import * as fs from 'fs'; import * as path from 'path'; +export type PackageManagerName = 'npm' | 'yarn' | 'pnpm' | 'bun'; + export interface DetectedPackageManager { - name: 'npm' | 'yarn' | 'pnpm' | 'bun'; + name: PackageManagerName; installCommand: string; } +/** + * Build the argv (executable + args, no shell) for adding a single package at an + * exact version with the given package manager. The version is pinned exactly + * (no `^`/`~`) so the installed runtime always matches the codegen pin. + * + * `packageSpec` must already be `name@version`. + */ +export function buildAddExactCommand(name: PackageManagerName, packageSpec: string): { exe: string; args: string[] } { + switch (name) { + case 'npm': + return { exe: 'npm', args: ['install', packageSpec, '--save-exact'] }; + case 'pnpm': + return { exe: 'pnpm', args: ['add', packageSpec, '--save-exact'] }; + case 'yarn': + return { exe: 'yarn', args: ['add', packageSpec, '--exact'] }; + case 'bun': + return { exe: 'bun', args: ['add', packageSpec, '--exact'] }; + } +} + export function detectPackageManager(workspaceDir: string): DetectedPackageManager { // Priority 1: Corepack packageManager field. const pkgJson = path.join(workspaceDir, 'package.json'); diff --git a/src/winapp-npm/src/jsbindings/path-safety.ts b/src/winapp-npm/src/jsbindings/path-safety.ts index d6cc2e97..033acb0c 100644 --- a/src/winapp-npm/src/jsbindings/path-safety.ts +++ b/src/winapp-npm/src/jsbindings/path-safety.ts @@ -166,7 +166,7 @@ export function assertSafeWorkspaceOutputDir(workspaceDir: string, outputDir: st throw new Error( `${label} ('${outputDir}') resolves to '${resolvedOutput}' which is outside the workspace ` + `('${resolvedWorkspace}'). The directory is wiped before each run, so it must be a ` + - "path strictly inside the workspace. Use a relative path like 'bindings' or an absolute path " + + "path strictly inside the workspace. Use a relative path like '.winapp/bindings' or an absolute path " + 'that descends from the workspace root.' ); } diff --git a/src/winapp-npm/src/jsbindings/runtime-dep-injector.ts b/src/winapp-npm/src/jsbindings/runtime-dep-injector.ts index ef1f1503..bd48befd 100644 --- a/src/winapp-npm/src/jsbindings/runtime-dep-injector.ts +++ b/src/winapp-npm/src/jsbindings/runtime-dep-injector.ts @@ -83,6 +83,27 @@ export function ensureRuntimeDependency( return { outcome: 'added', pinnedVersion: version }; } +/** + * Read-only check: is `packageName` declared as a production dependency in + * package.json? Used by the passive flows (`restore` / `node generate-bindings`) + * which never mutate package.json — they only warn when the runtime the + * generated bindings import is missing. Returns false when package.json is + * absent or the dep lives only under devDependencies. + */ +export function isRuntimeDependencyDeclared(workspaceDir: string, packageName: string): boolean { + const doc = readPackageJsonDoc(workspaceDir); + if (!doc) { + return false; + } + const deps = doc.parsed.dependencies; + return ( + !!deps && + typeof deps === 'object' && + !Array.isArray(deps) && + typeof (deps as Record)[packageName] === 'string' + ); +} + function insertOrUpdateDependency( obj: Record, packageName: string, @@ -166,7 +187,7 @@ export function getDynWinrtVersionPin(): string { export interface RuntimeDependencyHint { /** ANSI-friendly message to print. */ message: string; - /** True when the user should run ` install` to materialize a new dep. */ + /** True when the user should run ` install` to install a newly added dep locally. */ needsInstall: boolean; } @@ -180,7 +201,7 @@ export function formatRuntimeDependencyHint( switch (outcome) { case 'added': return { - message: `✅ Added ${packageName}@${pinnedVersion} to your package.json dependencies. Run \`${installCommand}\` to materialize it.`, + message: `✅ Added ${packageName}@${pinnedVersion} to your package.json dependencies. Run \`${installCommand}\` to install it locally.`, needsInstall: true, }; case 'alreadyPresent': diff --git a/src/winapp-npm/src/jsbindings/runtime-installer.ts b/src/winapp-npm/src/jsbindings/runtime-installer.ts new file mode 100644 index 00000000..6ca24282 --- /dev/null +++ b/src/winapp-npm/src/jsbindings/runtime-installer.ts @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. +// +// Materializes the `@microsoft/dynwinrt` runtime dependency into node_modules by +// invoking the workspace's package manager. Used by the `init` onboarding flow so +// freshly generated bindings are runnable without a manual second step. +// +// Best-effort by design: codegen has already succeeded and written the dependency +// into package.json, so an install failure (offline, private registry, missing +// package manager) degrades to a warning rather than failing the command. + +import { spawnSync } from 'child_process'; +import { PackageManagerName, buildAddExactCommand } from './package-manager-detector'; + +export interface RuntimeInstallResult { + ok: boolean; + /** Human-readable command that was attempted, e.g. `npm install pkg@1 --save-exact`. */ + command: string; + /** Set when ok is false. */ + error?: string; +} + +/** + * Spawn ` add @` (exact-pinned) in `workspaceDir`. + * + * Runs synchronously so the caller can map the exit code directly. The package + * manager is launched through a shell: on Windows the binaries are `.cmd` shims, + * and since the CVE-2024-27980 fix Node refuses to spawn `.cmd`/`.bat` files with + * `shell: false` (it fails with EINVAL). The full command is passed as a single + * string (no separate args array) so the shell resolves the shim and we avoid the + * DEP0190 arg-escaping deprecation warning. The args are constants we control, so + * there is no injection surface. + */ +export function installRuntimeDependency( + workspaceDir: string, + packageName: string, + version: string, + pmName: PackageManagerName +): RuntimeInstallResult { + const spec = `${packageName}@${version}`; + const { exe, args } = buildAddExactCommand(pmName, spec); + const command = `${exe} ${args.join(' ')}`; + + const result = spawnSync(command, { + cwd: workspaceDir, + stdio: ['ignore', 'pipe', 'pipe'], + shell: process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : true, + windowsHide: true, + encoding: 'utf8', + }); + + if (result.error) { + const code = (result.error as NodeJS.ErrnoException).code; + const reason = code === 'ENOENT' ? `${pmName} was not found on PATH` : result.error.message; + return { ok: false, command, error: reason }; + } + + if (result.status !== 0) { + const stderr = (result.stderr ?? '').toString().trim(); + const tail = stderr ? stderr.split(/\r?\n/).slice(-3).join(' ') : `exit ${result.status ?? 'null'}`; + return { ok: false, command, error: tail }; + } + + return { ok: true, command }; +} diff --git a/src/winapp-npm/src/winapp-commands.ts b/src/winapp-npm/src/winapp-commands.ts index 71acf95c..14356bfd 100644 --- a/src/winapp-npm/src/winapp-commands.ts +++ b/src/winapp-npm/src/winapp-commands.ts @@ -2,7 +2,7 @@ * AUTO-GENERATED — DO NOT EDIT * * Regenerate with: npm run generate-commands - * Source schema version: 0.3.2 + * Source schema version: 1.0.0 * * Programmatic wrappers for all winapp CLI commands. * Each function builds the CLI arguments, invokes the native CLI, diff --git a/src/winapp-npm/test/cli-args.test.ts b/src/winapp-npm/test/cli-args.test.ts new file mode 100644 index 00000000..09cfedbe --- /dev/null +++ b/src/winapp-npm/test/cli-args.test.ts @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; +import * as path from 'path'; + +import { + resolveWorkspaceDir, + firstPositional, + isVerbose, + isQuiet, + hasConfigOnly, + hasNoInstall, + hasUseDefaults, + resolveYamlPath, +} from '../src/cli-args'; + +test('firstPositional returns the first non-option token', () => { + assert.equal(firstPositional(['init', '--use-defaults']), 'init'); + assert.equal(firstPositional(['--quiet', 'myproj']), 'myproj'); +}); + +test('firstPositional skips the value of a value-taking option (space form)', () => { + // `--config-dir somedir` — `somedir` is the option value, not a positional. + assert.equal(firstPositional(['--config-dir', 'somedir', 'realpos']), 'realpos'); +}); + +test('firstPositional does not skip a token after an `--opt=value` form', () => { + assert.equal(firstPositional(['--config-dir=somedir', 'realpos']), 'realpos'); +}); + +test('firstPositional returns undefined when only options are present', () => { + assert.equal(firstPositional(['--quiet', '--verbose']), undefined); +}); + +test('resolveWorkspaceDir resolves the first positional to an absolute path', () => { + const got = resolveWorkspaceDir(['sub/dir']); + assert.equal(got, path.resolve('sub/dir')); +}); + +test('resolveWorkspaceDir falls back to cwd when there is no positional', () => { + assert.equal(resolveWorkspaceDir(['--quiet']), process.cwd()); +}); + +test('boolean flag helpers detect their flags anywhere in argv', () => { + assert.equal(isVerbose(['a', '--verbose']), true); + assert.equal(isVerbose(['a', '-v']), true); + assert.equal(isVerbose(['a']), false); + + assert.equal(isQuiet(['--quiet']), true); + assert.equal(isQuiet(['-q']), true); + assert.equal(isQuiet([]), false); + + assert.equal(hasConfigOnly(['--config-only']), true); + assert.equal(hasConfigOnly([]), false); + + assert.equal(hasNoInstall(['init', '--no-install']), true); + assert.equal(hasNoInstall(['init']), false); +}); + +test('hasUseDefaults recognises every accepted spelling', () => { + for (const flag of ['--use-defaults', '--no-prompt', '-y', '--yes']) { + assert.equal(hasUseDefaults(['init', flag]), true, `expected ${flag} to count as use-defaults`); + } + assert.equal(hasUseDefaults(['init']), false); +}); + +test('resolveYamlPath honours --config-dir (space and = forms)', () => { + assert.equal(resolveYamlPath(['--config-dir', 'cfg']), path.join(path.resolve('cfg'), 'winapp.yaml')); + assert.equal(resolveYamlPath(['--config-dir=cfg']), path.join(path.resolve('cfg'), 'winapp.yaml')); +}); + +test('resolveYamlPath defaults to /winapp.yaml without --config-dir', () => { + // A positional base-dir must NOT change where the yaml is read from. + assert.equal(resolveYamlPath(['init', 'someBaseDir']), path.join(process.cwd(), 'winapp.yaml')); +}); diff --git a/src/winapp-npm/test/esm-marker.test.ts b/src/winapp-npm/test/esm-marker.test.ts new file mode 100644 index 00000000..16ee8c12 --- /dev/null +++ b/src/winapp-npm/test/esm-marker.test.ts @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { ensureEsmPackageMarker } from '../src/jsbindings/codegen-runner'; + +function tmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'esm-marker-')); +} + +function readPkg(dir: string): Record { + return JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); +} + +test('ensureEsmPackageMarker creates a type:module package.json when none exists', () => { + const dir = tmpDir(); + try { + ensureEsmPackageMarker(dir); + assert.deepEqual(readPkg(dir), { type: 'module' }); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('ensureEsmPackageMarker preserves existing fields and sets type', () => { + const dir = tmpDir(); + try { + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '1.0.0' })); + ensureEsmPackageMarker(dir); + assert.deepEqual(readPkg(dir), { name: 'x', version: '1.0.0', type: 'module' }); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('ensureEsmPackageMarker leaves an already-correct package.json untouched', () => { + const dir = tmpDir(); + try { + const pkgPath = path.join(dir, 'package.json'); + fs.writeFileSync(pkgPath, JSON.stringify({ type: 'module', name: 'keep' })); + const before = fs.readFileSync(pkgPath, 'utf8'); + ensureEsmPackageMarker(dir); + assert.equal(fs.readFileSync(pkgPath, 'utf8'), before); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('ensureEsmPackageMarker overwrites a corrupt package.json', () => { + const dir = tmpDir(); + try { + fs.writeFileSync(path.join(dir, 'package.json'), 'not json {'); + ensureEsmPackageMarker(dir); + assert.deepEqual(readPkg(dir), { type: 'module' }); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/src/winapp-npm/test/lockfile-reader.test.ts b/src/winapp-npm/test/lockfile-reader.test.ts new file mode 100644 index 00000000..4b32a37e --- /dev/null +++ b/src/winapp-npm/test/lockfile-reader.test.ts @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { tryReadLockfile, getLockfilePath, LOCKFILE_SCHEMA_VERSION } from '../src/jsbindings/lockfile-reader'; + +function withWorkspace(fn: (dir: string) => void): void { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'winapp-lock-')); + try { + fn(dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +function writeLockfile(workspaceDir: string, body: unknown): void { + const filePath = getLockfilePath(workspaceDir); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, typeof body === 'string' ? body : JSON.stringify(body)); +} + +test('tryReadLockfile returns null with no reason when the lockfile is absent', () => { + withWorkspace((dir) => { + const result = tryReadLockfile(dir); + assert.equal(result.lockfile, null); + assert.equal(result.reason, undefined); + }); +}); + +test('tryReadLockfile parses a valid schema-3 lockfile', () => { + withWorkspace((dir) => { + const cacheDir = path.join(dir, 'cache'); + fs.mkdirSync(cacheDir, { recursive: true }); + const winmd = path.join(cacheDir, 'Some.winmd'); + fs.writeFileSync(winmd, ''); + writeLockfile(dir, { + schema: LOCKFILE_SCHEMA_VERSION, + nuget_cache_dir: cacheDir, + yaml_packages_hash: 'abc123', + packages: [{ name: 'Pkg', version: '1.0', winmds: [winmd] }], + }); + + const { lockfile, reason } = tryReadLockfile(dir); + assert.equal(reason, undefined); + assert.ok(lockfile); + assert.equal(lockfile!.schemaVersion, LOCKFILE_SCHEMA_VERSION); + assert.equal(lockfile!.yamlPackagesHash, 'abc123'); + assert.deepEqual(lockfile!.packages, [{ name: 'Pkg', version: '1.0', winmds: [winmd] }]); + }); +}); + +test('tryReadLockfile rejects a schema mismatch', () => { + withWorkspace((dir) => { + writeLockfile(dir, { schema: 999, nuget_cache_dir: dir, packages: [] }); + const { lockfile, reason } = tryReadLockfile(dir); + assert.equal(lockfile, null); + assert.match(reason ?? '', /schema mismatch/i); + }); +}); + +test('tryReadLockfile rejects a lockfile missing nuget_cache_dir', () => { + withWorkspace((dir) => { + writeLockfile(dir, { schema: LOCKFILE_SCHEMA_VERSION, packages: [] }); + const { lockfile, reason } = tryReadLockfile(dir); + assert.equal(lockfile, null); + assert.match(reason ?? '', /nuget_cache_dir/i); + }); +}); + +test('tryReadLockfile rejects invalid JSON', () => { + withWorkspace((dir) => { + writeLockfile(dir, '{ not json'); + const { lockfile, reason } = tryReadLockfile(dir); + assert.equal(lockfile, null); + assert.match(reason ?? '', /not valid JSON/i); + }); +}); + +test('tryReadLockfile refuses winmd paths outside the recorded nuget cache', () => { + withWorkspace((dir) => { + const cacheDir = path.join(dir, 'cache'); + fs.mkdirSync(cacheDir, { recursive: true }); + writeLockfile(dir, { + schema: LOCKFILE_SCHEMA_VERSION, + nuget_cache_dir: cacheDir, + packages: [{ name: 'Pkg', version: '1.0', winmds: ['C:\\Windows\\evil.winmd'] }], + }); + const { lockfile, reason } = tryReadLockfile(dir); + assert.equal(lockfile, null); + assert.match(reason ?? '', /outside the recorded/i); + }); +}); diff --git a/src/winapp-npm/test/package-manager-detector.test.ts b/src/winapp-npm/test/package-manager-detector.test.ts new file mode 100644 index 00000000..ea30a744 --- /dev/null +++ b/src/winapp-npm/test/package-manager-detector.test.ts @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { buildAddExactCommand, detectPackageManager } from '../src/jsbindings/package-manager-detector'; + +test('buildAddExactCommand pins exact versions per package manager', () => { + assert.deepEqual(buildAddExactCommand('npm', 'pkg@1.2.3'), { + exe: 'npm', + args: ['install', 'pkg@1.2.3', '--save-exact'], + }); + assert.deepEqual(buildAddExactCommand('pnpm', 'pkg@1.2.3'), { + exe: 'pnpm', + args: ['add', 'pkg@1.2.3', '--save-exact'], + }); + assert.deepEqual(buildAddExactCommand('yarn', 'pkg@1.2.3'), { + exe: 'yarn', + args: ['add', 'pkg@1.2.3', '--exact'], + }); + assert.deepEqual(buildAddExactCommand('bun', 'pkg@1.2.3'), { + exe: 'bun', + args: ['add', 'pkg@1.2.3', '--exact'], + }); +}); + +function withTempWorkspace(files: Record, fn: (dir: string) => void): void { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'winapp-pm-')); + try { + for (const [name, contents] of Object.entries(files)) { + fs.writeFileSync(path.join(dir, name), contents); + } + fn(dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +test('detectPackageManager prefers the corepack packageManager field', () => { + withTempWorkspace( + { + 'package.json': JSON.stringify({ packageManager: 'pnpm@9.1.0+sha512.abc' }), + // A conflicting lockfile must lose to the explicit corepack declaration. + 'yarn.lock': '', + }, + (dir) => { + assert.equal(detectPackageManager(dir).name, 'pnpm'); + } + ); +}); + +test('detectPackageManager sniffs lockfiles when no corepack field is present', () => { + withTempWorkspace({ 'pnpm-lock.yaml': '' }, (dir) => assert.equal(detectPackageManager(dir).name, 'pnpm')); + withTempWorkspace({ 'yarn.lock': '' }, (dir) => assert.equal(detectPackageManager(dir).name, 'yarn')); + withTempWorkspace({ 'bun.lockb': '' }, (dir) => assert.equal(detectPackageManager(dir).name, 'bun')); + withTempWorkspace({ 'package-lock.json': '' }, (dir) => assert.equal(detectPackageManager(dir).name, 'npm')); +}); + +test('detectPackageManager falls back to npm for an empty workspace', () => { + withTempWorkspace({}, (dir) => { + const detected = detectPackageManager(dir); + assert.equal(detected.name, 'npm'); + assert.equal(detected.installCommand, 'npm install'); + }); +}); + +test('detectPackageManager ignores an unparsable package.json and uses lockfiles', () => { + withTempWorkspace({ 'package.json': '{ not valid json', 'yarn.lock': '' }, (dir) => { + assert.equal(detectPackageManager(dir).name, 'yarn'); + }); +}); diff --git a/src/winapp-npm/test/path-safety.test.ts b/src/winapp-npm/test/path-safety.test.ts new file mode 100644 index 00000000..dbcc9c8c --- /dev/null +++ b/src/winapp-npm/test/path-safety.test.ts @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; + +import { isNetworkPath } from '../src/jsbindings/path-safety'; + +test('isNetworkPath returns false for empty and local drive-letter paths', () => { + assert.equal(isNetworkPath(''), false); + assert.equal(isNetworkPath('C:\\Users\\me\\proj'), false); + assert.equal(isNetworkPath('relative\\path'), false); +}); + +test('isNetworkPath flags plain UNC paths', () => { + assert.equal(isNetworkPath('\\\\server\\share'), true); + assert.equal(isNetworkPath('\\\\server\\share\\sub'), true); +}); + +test('isNetworkPath normalizes forward slashes before classifying', () => { + assert.equal(isNetworkPath('//server/share'), true); +}); + +test('isNetworkPath treats local DOS device paths as non-network', () => { + assert.equal(isNetworkPath('\\\\?\\C:\\foo'), false); + assert.equal(isNetworkPath('\\\\.\\C:\\foo'), false); +}); + +test('isNetworkPath flags DOS-device UNC paths (\\\\?\\UNC\\ and \\\\.\\UNC\\)', () => { + assert.equal(isNetworkPath('\\\\?\\UNC\\server\\share'), true); + assert.equal(isNetworkPath('\\\\.\\UNC\\server\\share'), true); + // Case-insensitive on the UNC token. + assert.equal(isNetworkPath('\\\\?\\unc\\server\\share'), true); +}); diff --git a/src/winapp-npm/test/yaml-packages-hash.test.ts b/src/winapp-npm/test/yaml-packages-hash.test.ts new file mode 100644 index 00000000..e899cb26 --- /dev/null +++ b/src/winapp-npm/test/yaml-packages-hash.test.ts @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; + +import { computeYamlPackagesHash, parsePackagesFromYaml } from '../src/jsbindings/yaml-packages-hash'; + +test('computeYamlPackagesHash matches the cross-language golden fixture', () => { + // Pinned byte-for-byte against the C# YamlPackagesHasherTests golden fixture. + const hash = computeYamlPackagesHash([ + { name: 'Microsoft.WindowsAppSDK', version: '2.1.3' }, + { name: 'Microsoft.Windows.SDK.CPP', version: '10.0.28000.1839' }, + ]); + assert.equal(hash, '8581abfcb53fa04056a066fc7098c5d94064cc275e20f0e547365c1b8b146e54'); +}); + +test('computeYamlPackagesHash is order-independent (canonical sort)', () => { + const a = computeYamlPackagesHash([ + { name: 'A.Pkg', version: '1.0' }, + { name: 'B.Pkg', version: '2.0' }, + ]); + const b = computeYamlPackagesHash([ + { name: 'B.Pkg', version: '2.0' }, + { name: 'A.Pkg', version: '1.0' }, + ]); + assert.equal(a, b); +}); + +test('computeYamlPackagesHash lowercases names and dedupes', () => { + const mixedCase = computeYamlPackagesHash([{ name: 'Microsoft.Foo', version: '1.0' }]); + const lower = computeYamlPackagesHash([{ name: 'microsoft.foo', version: '1.0' }]); + assert.equal(mixedCase, lower); + + const single = computeYamlPackagesHash([{ name: 'Dup', version: '1' }]); + const duped = computeYamlPackagesHash([ + { name: 'Dup', version: '1' }, + { name: 'dup', version: '1' }, + ]); + assert.equal(single, duped); +}); + +test('computeYamlPackagesHash skips whitespace-only names and treats missing version as empty', () => { + const withBlank = computeYamlPackagesHash([ + { name: ' ', version: '9' }, + { name: 'Real', version: '' }, + ]); + const onlyReal = computeYamlPackagesHash([{ name: 'Real', version: '' }]); + assert.equal(withBlank, onlyReal); +}); + +test('parsePackagesFromYaml extracts name/version pairs from the packages block', () => { + const yaml = [ + 'sdk: stable', + 'packages:', + ' - name: Microsoft.WindowsAppSDK', + ' version: 2.1.3', + ' - name: Microsoft.Windows.SDK.CPP', + ' version: 10.0.28000.1839', + ].join('\n'); + assert.deepEqual(parsePackagesFromYaml(yaml), [ + { name: 'Microsoft.WindowsAppSDK', version: '2.1.3' }, + { name: 'Microsoft.Windows.SDK.CPP', version: '10.0.28000.1839' }, + ]); +}); + +test('parsePackagesFromYaml ignores entries outside the packages block', () => { + const yaml = [ + 'other:', + ' - name: ShouldBeIgnored', + ' version: 0.0.0', + 'packages:', + ' - name: Kept', + ' version: 1.2.3', + ].join('\n'); + assert.deepEqual(parsePackagesFromYaml(yaml), [{ name: 'Kept', version: '1.2.3' }]); +}); + +test('parsePackagesFromYaml strips inline comments and surrounding quotes from scalars', () => { + const yaml = [ + 'packages:', + ' - name: Microsoft.WindowsAppSDK # the main SDK', + " version: '2.1.3'", + ' - name: "Quoted.Pkg"', + ' version: 4.5.6 # trailing comment', + ].join('\n'); + assert.deepEqual(parsePackagesFromYaml(yaml), [ + { name: 'Microsoft.WindowsAppSDK', version: '2.1.3' }, + { name: 'Quoted.Pkg', version: '4.5.6' }, + ]); +}); + +test('parsePackagesFromYaml returns an empty list when there is no packages block', () => { + assert.deepEqual(parsePackagesFromYaml('sdk: stable\n'), []); +}); diff --git a/src/winapp-npm/tsconfig.test.json b/src/winapp-npm/tsconfig.test.json new file mode 100644 index 00000000..90ff7424 --- /dev/null +++ b/src/winapp-npm/tsconfig.test.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "./dist-test", + "declaration": false, + "declarationMap": false, + "noEmit": false + }, + "include": ["src/**/*", "test/**/*"], + "exclude": ["node_modules", "dist", "dist-test", "bin", "addon-template", "cs-addon-template", "examples"] +} From fb17d12f35f69276a057fc4606ffdb7496671b15 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Mon, 1 Jun 2026 21:46:55 +0800 Subject: [PATCH 24/27] fix lint and doc --- .github/plugin/skills/winapp-cli/frameworks/SKILL.md | 2 +- docs/guides/electron/js-file-picker.md | 2 +- src/winapp-npm/src/jsbindings/orchestrator.ts | 7 ++++++- src/winapp-npm/src/jsbindings/package-json-config.ts | 3 +-- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/plugin/skills/winapp-cli/frameworks/SKILL.md b/.github/plugin/skills/winapp-cli/frameworks/SKILL.md index 81319146..cde43d7d 100644 --- a/.github/plugin/skills/winapp-cli/frameworks/SKILL.md +++ b/.github/plugin/skills/winapp-cli/frameworks/SKILL.md @@ -35,7 +35,7 @@ Use the **npm package** (`@Microsoft/WinAppCli`), **not** the standalone CLI. Th Quick start: ```powershell npm install --save-dev @microsoft/winappcli -npx winapp init . --use-defaults # fresh init: scaffolds winapp.yaml + JS bindings + C++ projections +npx winapp init . --use-defaults # fresh init: scaffolds winapp.yaml + JS bindings + C++ projections npx winapp node create-addon --template cs # create a C# native addon (for what the JS bindings can't drive — see below) npx winapp node add-electron-debug-identity # register identity for debugging ``` diff --git a/docs/guides/electron/js-file-picker.md b/docs/guides/electron/js-file-picker.md index 8f0e86a8..b6dc8e68 100644 --- a/docs/guides/electron/js-file-picker.md +++ b/docs/guides/electron/js-file-picker.md @@ -148,7 +148,7 @@ npx winapp node generate-bindings ## Customizing the binding scope (optional) -By default — the empty `"jsBindings": {}` block that `init` adds — `winapp` generates bindings for the WinAppSDK packages in your `winapp.yaml`, minus a few that can't be driven from a headless Node process (XAML/WinUI and WebView2 are excluded by default). To narrow or extend that, configure the `winapp.jsBindings` namespace in `package.json` (the schema lives in `package.json`, not `winapp.yaml`, the same convention used by `eslint`, `jest`, `prettier`, …): +When `jsBindings` is `{}` (the default block `init` adds), bindings are generated for all Windows App SDK APIs in your `winapp.yaml`, minus a few that can't be driven from a headless Node process (XAML/WinUI and WebView2 are excluded by default). To narrow or extend that, configure the `winapp.jsBindings` namespace in `package.json` (the schema lives in `package.json`, not `winapp.yaml`, the same convention used by `eslint`, `jest`, `prettier`, …): ```jsonc // package.json diff --git a/src/winapp-npm/src/jsbindings/orchestrator.ts b/src/winapp-npm/src/jsbindings/orchestrator.ts index 2cd985f1..7cf96669 100644 --- a/src/winapp-npm/src/jsbindings/orchestrator.ts +++ b/src/winapp-npm/src/jsbindings/orchestrator.ts @@ -10,7 +10,12 @@ import { tryReadLockfile } from './lockfile-reader'; import { partitionPackageWinmds } from './winmd-policy'; import { resolveAdditionalWinmds } from './additional-winmds'; import { runCodegen } from './codegen-runner'; -import { ensureRuntimeDependency, formatRuntimeDependencyHint, getDynWinrtVersionPin, isRuntimeDependencyDeclared } from './runtime-dep-injector'; +import { + ensureRuntimeDependency, + formatRuntimeDependencyHint, + getDynWinrtVersionPin, + isRuntimeDependencyDeclared, +} from './runtime-dep-injector'; import { detectPackageManager } from './package-manager-detector'; import { installRuntimeDependency } from './runtime-installer'; import { startSpinner, Spinner } from './spinner'; diff --git a/src/winapp-npm/src/jsbindings/package-json-config.ts b/src/winapp-npm/src/jsbindings/package-json-config.ts index 3094b2d3..37a7c616 100644 --- a/src/winapp-npm/src/jsbindings/package-json-config.ts +++ b/src/winapp-npm/src/jsbindings/package-json-config.ts @@ -68,8 +68,7 @@ export function ensureJsBindingsBlock( writeJsBindingsConfig(workspaceDir, defaultJsBindingsConfig()); if (!opts.quiet) { console.log( - 'ℹ️ Added "winapp.jsBindings" to package.json. ' + - 'Edit `additionalWinmds` or `additionalRefs` to customize.' + 'ℹ️ Added "winapp.jsBindings" to package.json. ' + 'Edit `additionalWinmds` or `additionalRefs` to customize.' ); } return 'added'; From 8b9b4d1bef2a56b061e2b5b904965c5b36595e41 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Wed, 3 Jun 2026 21:38:35 +0800 Subject: [PATCH 25/27] fix local comments --- samples/electron/README.md | 8 +- src/winapp-npm/src/cli-args.ts | 37 ++++++-- src/winapp-npm/src/cli.ts | 72 ++++++++++++++-- .../jsbindings/package-manager-detector.ts | 50 +++++++++++ .../src/jsbindings/runtime-installer.ts | 70 ++++++++++++--- src/winapp-npm/src/winapp-commands.ts | 6 +- src/winapp-npm/test/cli-args.test.ts | 18 +++- .../test/package-manager-detector.test.ts | 86 ++++++++++++++++++- src/winapp-npm/test/runtime-installer.test.ts | 25 ++++++ 9 files changed, 336 insertions(+), 36 deletions(-) create mode 100644 src/winapp-npm/test/runtime-installer.test.ts diff --git a/samples/electron/README.md b/samples/electron/README.md index bf33c9f6..a90dcfc0 100644 --- a/samples/electron/README.md +++ b/samples/electron/README.md @@ -41,12 +41,14 @@ The sample is a default Electron Forge generated application with the following The `.winapp` folder is added to `.gitignore` to ensure it is not committed to git. Running `npx winapp restore` will restore it (this is added as a postinstall script in `package.json`). -2. **Generated a native addon** using `npx winapp node generate-addon` to call APIs from the Windows SDK and Windows App SDK. The addon folder contains the generated addon alongside the `build-addon` script added to `package.json`. The addon contains a function to raise a Windows notification, and the JavaScript code has been modified to call this function. +2. **Generated JS bindings** when running `npx winapp init`. The generated bindings let you call Windows App SDK APIs directly from JavaScript, with no native addon build step. The `.winapp/bindings/` folder contains a typed `.js` + `.d.ts` pair per WinRT class alongside an `index.js` that re-exports them all. Re-run `npx winapp node generate-bindings` to regenerate them after editing `winapp.jsBindings`. -3. **Generated a C# addon** using `npx winapp node create-addon --template cs`. This generates a simple c# addon using the node-api-dotnet project. When you build the C# addon, this will use NAOT to produce +3. **Generated a native addon** using `npx winapp node generate-addon` to call APIs from the Windows SDK and Windows App SDK. The addon folder contains the generated addon alongside the `build-addon` script added to `package.json`. The addon contains a function to raise a Windows notification, and the JavaScript code has been modified to call this function. + +4. **Generated a C# addon** using `npx winapp node create-addon --template cs`. This generates a simple c# addon using the node-api-dotnet project. When you build the C# addon, this will use NAOT to produce a .node file that is trimmed and doesn't require the .net runtime to be installed on the target machine. -4. **Modified `forge.config.js`** to ignore the `.winapp` and `winapp.yaml` files from the final package, and to copy the `appxmanifest.xml` and `Assets` folder to the final package. +5. **Modified `forge.config.js`** to ignore the `.winapp` and `winapp.yaml` files from the final package, and to copy the `appxmanifest.xml` and `Assets` folder to the final package. ## Prerequisites diff --git a/src/winapp-npm/src/cli-args.ts b/src/winapp-npm/src/cli-args.ts index 5407770e..63b348e9 100644 --- a/src/winapp-npm/src/cli-args.ts +++ b/src/winapp-npm/src/cli-args.ts @@ -74,27 +74,46 @@ export function hasNoInstall(args: readonly string[]): boolean { return args.includes('--no-install'); } +/** + * Flags the npm wrapper handles itself and that the native CLI does NOT + * recognize. They must be stripped from any argv forwarded to the native CLI, + * or it errors out on the unknown option. + */ +export const WRAPPER_ONLY_FLAGS: ReadonlySet = new Set(['--no-install']); + +/** Remove wrapper-only flags (e.g. `--no-install`) before forwarding to native. */ +export function stripWrapperOnlyFlags(args: readonly string[]): string[] { + return args.filter((a) => !WRAPPER_ONLY_FLAGS.has(a)); +} + /** Detect `--use-defaults` / `--no-prompt` / `-y` / `--yes`. */ export function hasUseDefaults(args: readonly string[]): boolean { return args.some((a) => USE_DEFAULTS_FLAGS.has(a)); } /** - * Resolve the effective `winapp.yaml` path the native CLI will read, so the + * Resolve the effective `winapp.yaml` path the native CLI reads, so the * orchestrator's staleness check (`yaml_packages_hash`) compares against the - * SAME file native used. Mirrors `InitCommand`/`RestoreCommand` semantics: + * SAME file native used. Explicit `--config-dir` always wins: * * --config-dir / --config-dir=/winapp.yaml - * (no --config-dir) → /winapp.yaml * - * Note: `--config-dir` defaults to **current directory**, NOT to the - * `base-directory` positional. So a positional base-dir alone does NOT - * change where the yaml is read from — only `--config-dir` does. Don't - * derive the yaml location from `workspaceDir`. + * Without `--config-dir`, the default differs per command and the caller must + * supply the correct `defaultConfigDir`: + * + * • `restore` / `node generate-bindings`: native `RestoreCommand` defaults + * ConfigDir to the current directory regardless of any `base-directory` + * positional, so pass `process.cwd()` (the default here). + * • `init`: native `InitCommand` remaps ConfigDir to the *selected* init + * directory when `--config-dir` is not explicit (InitCommand.cs:122-126), + * which the wrapper approximates as `workspaceDir`. So pass `workspaceDir` + * — otherwise `winapp init ` hashes the wrong (cwd) yaml and the + * orchestrator reports a false stale-lockfile failure right after a + * successful native init. */ -export function resolveYamlPath(args: readonly string[]): string { +export function resolveYamlPath(args: readonly string[], defaultConfigDir: string = process.cwd()): string { const explicit = extractConfigDir(args); - const configDir = explicit ? path.resolve(explicit) : process.cwd(); + const configDir = explicit ? path.resolve(explicit) : path.resolve(defaultConfigDir); return path.join(configDir, 'winapp.yaml'); } diff --git a/src/winapp-npm/src/cli.ts b/src/winapp-npm/src/cli.ts index c381b811..b85507c8 100644 --- a/src/winapp-npm/src/cli.ts +++ b/src/winapp-npm/src/cli.ts @@ -8,7 +8,16 @@ import { askBindingsKind, parseSetupSdksArg } from './jsbindings/init-prompt'; import { hasJsBindings, ensureJsBindingsBlock } from './jsbindings/package-json-config'; import { runJsBindingsPipeline } from './jsbindings/orchestrator'; import { getLockfilePath, LOCKFILE_NAME } from './jsbindings/lockfile-reader'; -import { resolveWorkspaceDir, resolveYamlPath, isVerbose, isQuiet, hasConfigOnly, hasNoInstall } from './cli-args'; +import { readWinappYamlPackages } from './jsbindings/yaml-packages-hash'; +import { + resolveWorkspaceDir, + resolveYamlPath, + isVerbose, + isQuiet, + hasConfigOnly, + hasNoInstall, + stripWrapperOnlyFlags, +} from './cli-args'; import { assertSafeWorkspaceFile } from './jsbindings/path-safety'; import { spawn } from 'child_process'; import * as fs from 'fs'; @@ -80,6 +89,15 @@ export async function main(): Promise { return; } + // `init --help` falls through to native help, which has no knowledge of the + // wrapper-only options we add (e.g. --no-install). Run native help, then + // append a short addendum so the flag is discoverable. + if (command === 'init' && commandArgs.some((a) => HELP_FLAGS.has(a))) { + await callWinappCli(stripWrapperOnlyFlags(args), { exitOnError: true }); + printInitWrapperOnlyHelp(); + return; + } + // Intercept init/restore so we can run the JS bindings pre-/post-hooks // around the native command. Help / completion flags bypass the hook. // @@ -92,7 +110,9 @@ export async function main(): Promise { if (INTERCEPTED_COMMANDS.has(command) && !commandArgs.some((a) => HELP_FLAGS.has(a))) { if (command === 'init') { if (parseSetupSdksArg(commandArgs) === 'none') { - await callWinappCli(args, { exitOnError: true }); + // Fast path: no JS bindings to wire up. Still strip wrapper-only + // flags (e.g. --no-install) — the native CLI rejects them. + await callWinappCli(stripWrapperOnlyFlags(args), { exitOnError: true }); return; } await handleInit(commandArgs); @@ -596,6 +616,7 @@ async function handleGenerateBindings(args: string[]): Promise { console.log(''); console.log('Options:'); console.log(' --verbose Enable verbose codegen output (default: false)'); + console.log(' --quiet, -q Suppress progress and informational output'); console.log(' --help Show this help'); console.log(''); console.log('Examples:'); @@ -646,6 +667,18 @@ async function handleGenerateBindings(args: string[]): Promise { await runJsBindingsOrchestrator(workspaceDir, isVerbose(args), quiet, resolveYamlPath(args)); } +/** + * Print the npm-wrapper-only options for `init` that the native `--help` output + * does not know about. Appended after native help so users can discover them. + */ +function printInitWrapperOnlyHelp(): void { + console.log(''); + console.log(`Options (added by the ${CLI_NAME} npm wrapper):`); + console.log(' --no-install Skip auto-installing the @microsoft/dynwinrt runtime'); + console.log(' dependency into node_modules after generating JS bindings'); + console.log(' (the dependency is still added to package.json).'); +} + /** * `init` intercept: ask the JS bindings prompt, run native init, then (when * the user wants JS bindings) write the `"winapp.jsBindings"` namespace to @@ -676,7 +709,7 @@ async function handleInit(args: string[]): Promise { // cwd=/foo/subdir + arg `subdir` → native lands in /foo/subdir/subdir). // Wrapper-side `workspaceDir` is an absolute path we only use for our OWN // bookkeeping (package.json read/write, codegen output, prompts). - const nativeArgs = args.filter((a) => a !== '--no-install'); + const nativeArgs = stripWrapperOnlyFlags(args); await callWinappCli(['init', ...nativeArgs], { exitOnError: true }); // SDK setup writes .winapp/winmds.lock.json during winmd discovery (Step 5), @@ -768,7 +801,19 @@ async function handleInit(args: string[]): Promise { // init is the onboarding flow: it is the only command that *writes* the // runtime dependency to package.json (manageRuntimeDep) and, unless the user // opted out with --no-install, also installs it into node_modules. - await runJsBindingsOrchestrator(workspaceDir, isVerbose(args), quiet, resolveYamlPath(args), !noInstall, true); + // + // Native init remaps --config-dir to the selected init directory when not + // explicit, so resolve the yaml against workspaceDir (not cwd) — otherwise + // `winapp init ` would hash the wrong file and report a false + // stale-lockfile failure. + await runJsBindingsOrchestrator( + workspaceDir, + isVerbose(args), + quiet, + resolveYamlPath(args, workspaceDir), + !noInstall, + true + ); } /** @@ -803,7 +848,24 @@ async function handleRestore(args: string[]): Promise { return; } - await runJsBindingsOrchestrator(workspaceDir, isVerbose(args), quiet, resolveYamlPath(args)); + // A *stale* lockfile from a previous restore can linger after the user removes + // winapp.yaml or empties its `packages:` block. Native restore treats that as a + // no-op success (it writes no fresh lockfile), but the orchestrator would then + // report the lingering lockfile as stale → exit 1. Preserve the no-op contract: + // when there are no packages to restore, there are no bindings to generate. + const restoreYamlPath = resolveYamlPath(args); + const yamlPackages = readWinappYamlPackages(workspaceDir, restoreYamlPath); + if (!yamlPackages || yamlPackages.length === 0) { + if (!quiet) { + console.log( + 'ℹ️ winapp.yaml has no packages, so JS bindings were not generated. ' + + 'Add packages to `winapp.yaml` and re-run `npx winapp restore`.' + ); + } + return; + } + + await runJsBindingsOrchestrator(workspaceDir, isVerbose(args), quiet, restoreYamlPath); } /** Runs the JS bindings pipeline and translates outcomes into exit codes. */ diff --git a/src/winapp-npm/src/jsbindings/package-manager-detector.ts b/src/winapp-npm/src/jsbindings/package-manager-detector.ts index c7aacfae..84756e12 100644 --- a/src/winapp-npm/src/jsbindings/package-manager-detector.ts +++ b/src/winapp-npm/src/jsbindings/package-manager-detector.ts @@ -41,6 +41,56 @@ export function buildAddExactCommand(name: PackageManagerName, packageSpec: stri } } +/** + * Resolve the absolute path to a package manager's launcher by scanning + * `process.env.PATH` (and `PATHEXT` on Windows), mirroring the OS executable + * lookup. Returns `null` when the executable cannot be found (or `PATH` is + * unset), so callers can degrade to a best-effort warning. + * + * SECURITY (why we don't just spawn a bare `npm`): on Windows the launchers are + * `.cmd` shims, and `cmd.exe` (and `node-which`) resolve a bare command name + * against the CURRENT DIRECTORY *before* `PATH`. If we spawned `npm` with + * `cwd` set to an untrusted workspace, a malicious `npm.cmd` dropped in that + * workspace would hijack execution (CWE-426 untrusted search path). We + * deliberately scan ONLY `PATH` here — never the workspace / `process.cwd()` — + * and hand the resulting ABSOLUTE path to the spawn, which makes `cmd.exe` + * skip its current-directory lookup entirely. + * + * Note: `process.env.PATH` is read through Node's case-insensitive `process.env` + * proxy on Windows, so it resolves a `Path`/`path` variable too. + */ +export function resolvePackageManagerPath(name: PackageManagerName): string | null { + const rawPath = process.env.PATH; + if (!rawPath) { + return null; + } + const dirs = rawPath.split(path.delimiter).filter((d) => d.length > 0); + + // On Windows, try each PATHEXT extension (npm → npm.cmd / npm.exe). On other + // platforms the launcher has no extension. + const exts = + process.platform === 'win32' + ? (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD') + .split(';') + .map((e) => e.trim()) + .filter((e) => e.length > 0) + : ['']; + + for (const dir of dirs) { + for (const ext of exts) { + const candidate = path.join(dir, `${name}${ext}`); + try { + if (fs.statSync(candidate).isFile()) { + return candidate; + } + } catch { + // Not present / not accessible — keep scanning. + } + } + } + return null; +} + export function detectPackageManager(workspaceDir: string): DetectedPackageManager { // Priority 1: Corepack packageManager field. const pkgJson = path.join(workspaceDir, 'package.json'); diff --git a/src/winapp-npm/src/jsbindings/runtime-installer.ts b/src/winapp-npm/src/jsbindings/runtime-installer.ts index 6ca24282..6e90ecf2 100644 --- a/src/winapp-npm/src/jsbindings/runtime-installer.ts +++ b/src/winapp-npm/src/jsbindings/runtime-installer.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation and Contributors. All rights reserved. // Licensed under the MIT License. // -// Materializes the `@microsoft/dynwinrt` runtime dependency into node_modules by +// Installs the `@microsoft/dynwinrt` runtime dependency into node_modules by // invoking the workspace's package manager. Used by the `init` onboarding flow so // freshly generated bindings are runnable without a manual second step. // @@ -9,8 +9,8 @@ // into package.json, so an install failure (offline, private registry, missing // package manager) degrades to a warning rather than failing the command. -import { spawnSync } from 'child_process'; -import { PackageManagerName, buildAddExactCommand } from './package-manager-detector'; +import { spawnSync, SpawnSyncOptions } from 'child_process'; +import { PackageManagerName, buildAddExactCommand, resolvePackageManagerPath } from './package-manager-detector'; export interface RuntimeInstallResult { ok: boolean; @@ -21,15 +21,44 @@ export interface RuntimeInstallResult { } /** - * Spawn ` add @` (exact-pinned) in `workspaceDir`. + * Build the `cmd.exe /d /s /c` command line for launching an absolute-path `.cmd` + * shim. The ENTIRE command (quoted-exe + args) is wrapped in an extra outer pair + * of quotes: with `/s`, cmd.exe strips the first and last quote of the string, + * leaving the inner `"C:\...\npm.cmd" ` intact so a path containing spaces + * still resolves. This mirrors the cross-spawn / @npmcli/promise-spawn pattern. + */ +/** Visible for unit tests. */ +export function buildWindowsCmdLine(exePath: string, args: string[]): string { + const inner = `"${exePath}" ${args.map(quoteForCmd).join(' ')}`; + return `"${inner}"`; +} + +/** + * Quote a single argument for a `cmd.exe /c ""` string. Our args are + * controlled constants (`install`, `@`, `--save-exact`), so this + * only needs to guard the rare case of a version/name containing whitespace or + * cmd metacharacters — it is not a general-purpose shell escaper. + */ +function quoteForCmd(arg: string): string { + if (!/[\s"^&|<>()%!]/.test(arg)) { + return arg; + } + // Double embedded quotes per cmd.exe rules, then wrap the whole thing. + return `"${arg.replace(/"/g, '""')}"`; +} + +/** + * Install ` add @` (exact-pinned) into `workspaceDir`. * - * Runs synchronously so the caller can map the exit code directly. The package - * manager is launched through a shell: on Windows the binaries are `.cmd` shims, - * and since the CVE-2024-27980 fix Node refuses to spawn `.cmd`/`.bat` files with - * `shell: false` (it fails with EINVAL). The full command is passed as a single - * string (no separate args array) so the shell resolves the shim and we avoid the - * DEP0190 arg-escaping deprecation warning. The args are constants we control, so - * there is no injection surface. + * Runs synchronously so the caller can map the exit code directly. SECURITY: we + * resolve the package manager to an ABSOLUTE path from `PATH` first + * (`resolvePackageManagerPath`), never spawning a bare command name. On Windows + * the launchers are `.cmd` shims; spawning `cmd.exe` with the absolute `.cmd` + * path (rather than `{ shell: true }` + a bare name) is the pattern recommended + * by the Node docs post-CVE-2024-27980 — it avoids the EINVAL that `.cmd` + + * `shell: false` would raise, and because the path is absolute, `cmd.exe` does + * NOT perform its current-directory-first lookup, so a malicious `npm.cmd` in + * `workspaceDir` cannot hijack execution. */ export function installRuntimeDependency( workspaceDir: string, @@ -41,13 +70,26 @@ export function installRuntimeDependency( const { exe, args } = buildAddExactCommand(pmName, spec); const command = `${exe} ${args.join(' ')}`; - const result = spawnSync(command, { + const exePath = resolvePackageManagerPath(pmName); + if (!exePath) { + return { ok: false, command, error: `${pmName} was not found on PATH` }; + } + + const spawnOptions: SpawnSyncOptions = { cwd: workspaceDir, stdio: ['ignore', 'pipe', 'pipe'], - shell: process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : true, windowsHide: true, encoding: 'utf8', - }); + }; + + const result = + process.platform === 'win32' + ? spawnSync('cmd.exe', ['/d', '/s', '/c', buildWindowsCmdLine(exePath, args)], { + ...spawnOptions, + shell: false, + windowsVerbatimArguments: true, + }) + : spawnSync(exePath, args, { ...spawnOptions, shell: false }); if (result.error) { const code = (result.error as NodeJS.ErrnoException).code; diff --git a/src/winapp-npm/src/winapp-commands.ts b/src/winapp-npm/src/winapp-commands.ts index d66aae5a..fab8a5b4 100644 --- a/src/winapp-npm/src/winapp-commands.ts +++ b/src/winapp-npm/src/winapp-commands.ts @@ -2,7 +2,7 @@ * AUTO-GENERATED — DO NOT EDIT * * Regenerate with: npm run generate-commands - * Source schema version: 1.0.0 + * Source schema version: 0.3.2 * * Programmatic wrappers for all winapp CLI commands. * Each function builds the CLI arguments, invokes the native CLI, @@ -241,7 +241,7 @@ export async function getWinappPath(options: GetWinappPathOptions = {}): Promise export interface InitOptions extends CommonOptions { /** Base/root directory for the winapp workspace, for consumption or installation. */ baseDirectory?: string; - /** Directory to read/store configuration (default: current directory) */ + /** Directory to read/store configuration (default: the selected project directory, or current directory if no project is detected) */ configDir?: string; /** Only handle configuration file operations (create if missing, validate if exists). Skip package installation and other workspace setup steps. */ configOnly?: boolean; @@ -251,7 +251,7 @@ export interface InitOptions extends CommonOptions { noGitignore?: boolean; /** SDK installation mode: 'stable' (default), 'preview', 'experimental', or 'none' (skip SDK installation) */ setupSdks?: SdkInstallMode; - /** Do not prompt, and use default of all prompts */ + /** Do not prompt; requires an explicit project directory (e.g., winapp init . --use-defaults) */ useDefaults?: boolean; } diff --git a/src/winapp-npm/test/cli-args.test.ts b/src/winapp-npm/test/cli-args.test.ts index 09cfedbe..18f6930b 100644 --- a/src/winapp-npm/test/cli-args.test.ts +++ b/src/winapp-npm/test/cli-args.test.ts @@ -72,6 +72,22 @@ test('resolveYamlPath honours --config-dir (space and = forms)', () => { }); test('resolveYamlPath defaults to /winapp.yaml without --config-dir', () => { - // A positional base-dir must NOT change where the yaml is read from. + // restore / generate-bindings default the config dir to cwd; a positional + // base-dir must NOT change where the yaml is read from for those commands. assert.equal(resolveYamlPath(['init', 'someBaseDir']), path.join(process.cwd(), 'winapp.yaml')); }); + +test('resolveYamlPath uses the supplied defaultConfigDir when no --config-dir', () => { + // init passes workspaceDir so `winapp init ` hashes the yaml native + // actually wrote (remapped to the selected directory). + const baseDir = path.resolve('someBaseDir'); + assert.equal(resolveYamlPath(['init', 'someBaseDir'], baseDir), path.join(baseDir, 'winapp.yaml')); +}); + +test('resolveYamlPath: explicit --config-dir overrides the defaultConfigDir', () => { + assert.equal(resolveYamlPath(['init', 'base'], path.resolve('base')), path.join(path.resolve('base'), 'winapp.yaml')); + assert.equal( + resolveYamlPath(['--config-dir', 'cfg'], path.resolve('base')), + path.join(path.resolve('cfg'), 'winapp.yaml') + ); +}); diff --git a/src/winapp-npm/test/package-manager-detector.test.ts b/src/winapp-npm/test/package-manager-detector.test.ts index ea30a744..ac6f2a9b 100644 --- a/src/winapp-npm/test/package-manager-detector.test.ts +++ b/src/winapp-npm/test/package-manager-detector.test.ts @@ -7,7 +7,11 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { buildAddExactCommand, detectPackageManager } from '../src/jsbindings/package-manager-detector'; +import { + buildAddExactCommand, + detectPackageManager, + resolvePackageManagerPath, +} from '../src/jsbindings/package-manager-detector'; test('buildAddExactCommand pins exact versions per package manager', () => { assert.deepEqual(buildAddExactCommand('npm', 'pkg@1.2.3'), { @@ -73,3 +77,83 @@ test('detectPackageManager ignores an unparsable package.json and uses lockfiles assert.equal(detectPackageManager(dir).name, 'yarn'); }); }); + +const isWin = process.platform === 'win32'; +// On Windows the launcher is a PATHEXT-extension file (npm.cmd); elsewhere it's bare. +const launcherExt = isWin ? '.cmd' : ''; + +function withEnv(overrides: Record, fn: () => void): void { + const saved: Record = {}; + for (const k of Object.keys(overrides)) { + saved[k] = process.env[k]; + if (overrides[k] === undefined) { + delete process.env[k]; + } else { + process.env[k] = overrides[k]; + } + } + try { + fn(); + } finally { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) { + delete process.env[k]; + } else { + process.env[k] = v; + } + } + } +} + +test('resolvePackageManagerPath returns the absolute path of a launcher found on PATH', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'winapp-which-')); + try { + const launcher = path.join(dir, `npm${launcherExt}`); + fs.writeFileSync(launcher, ''); + withEnv({ PATH: dir, PATHEXT: '.COM;.EXE;.BAT;.CMD' }, () => { + const resolved = resolvePackageManagerPath('npm'); + // On Windows the returned extension casing follows PATHEXT (.CMD) while the + // file is npm.cmd; both refer to the same file on a case-insensitive FS. + assert.equal(resolved?.toLowerCase(), launcher.toLowerCase()); + }); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('resolvePackageManagerPath returns null when PATH is unset', () => { + withEnv({ PATH: undefined, Path: undefined }, () => { + assert.equal(resolvePackageManagerPath('npm'), null); + }); +}); + +test('resolvePackageManagerPath returns null when the launcher is not on PATH', () => { + const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'winapp-which-empty-')); + try { + withEnv({ PATH: emptyDir, PATHEXT: '.COM;.EXE;.BAT;.CMD' }, () => { + assert.equal(resolvePackageManagerPath('pnpm'), null); + }); + } finally { + fs.rmSync(emptyDir, { recursive: true, force: true }); + } +}); + +test('resolvePackageManagerPath ignores a launcher in the current directory (shim-hijack defense)', () => { + // A malicious `npm.cmd` dropped in the workspace/cwd must NOT be resolved: + // only PATH is scanned, never process.cwd(). + const cwdDir = fs.mkdtempSync(path.join(os.tmpdir(), 'winapp-which-cwd-')); + const emptyPathDir = fs.mkdtempSync(path.join(os.tmpdir(), 'winapp-which-pathonly-')); + const savedCwd = process.cwd(); + try { + fs.writeFileSync(path.join(cwdDir, `npm${launcherExt}`), ''); + process.chdir(cwdDir); + withEnv({ PATH: emptyPathDir, PATHEXT: '.COM;.EXE;.BAT;.CMD' }, () => { + // PATH has no npm launcher; the one in cwd must be ignored. + assert.equal(resolvePackageManagerPath('npm'), null); + }); + } finally { + process.chdir(savedCwd); + fs.rmSync(cwdDir, { recursive: true, force: true }); + fs.rmSync(emptyPathDir, { recursive: true, force: true }); + } +}); diff --git a/src/winapp-npm/test/runtime-installer.test.ts b/src/winapp-npm/test/runtime-installer.test.ts new file mode 100644 index 00000000..40fac291 --- /dev/null +++ b/src/winapp-npm/test/runtime-installer.test.ts @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; + +import { buildWindowsCmdLine } from '../src/jsbindings/runtime-installer'; + +test('buildWindowsCmdLine wraps the whole command in an outer quote pair', () => { + // cmd.exe /s strips the first & last quote of the command string, so the + // outer pair must be present for the inner exe-path quotes (needed for spaces + // in "C:\Program Files\...") to survive. + const line = buildWindowsCmdLine('C:\\Program Files\\nodejs\\npm.cmd', ['install', 'pkg@1.2.3', '--save-exact']); + assert.equal(line, '""C:\\Program Files\\nodejs\\npm.cmd" install pkg@1.2.3 --save-exact"'); +}); + +test('buildWindowsCmdLine quotes args containing whitespace or cmd metacharacters', () => { + const line = buildWindowsCmdLine('C:\\tools\\npm.cmd', ['install', 'pkg@1.0.0 beta']); + assert.equal(line, '""C:\\tools\\npm.cmd" install "pkg@1.0.0 beta""'); +}); + +test('buildWindowsCmdLine leaves simple args unquoted', () => { + const line = buildWindowsCmdLine('C:\\tools\\npm.cmd', ['install', 'pkg@1.0.0', '--save-exact']); + assert.equal(line, '""C:\\tools\\npm.cmd" install pkg@1.0.0 --save-exact"'); +}); From 0c622980f7545a8219d12862a935663d78d51324 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 4 Jun 2026 20:06:05 +0800 Subject: [PATCH 26/27] fix comments and local PR review --- .../skills/winapp-cli/frameworks/SKILL.md | 22 +++--- .../plugin/skills/winapp-cli/setup/SKILL.md | 2 +- .../fragments/skills/winapp-cli/frameworks.md | 20 +++--- docs/fragments/skills/winapp-cli/setup.md | 2 +- docs/usage.md | 2 +- scripts/generate-llm-docs.ps1 | 2 +- src/winapp-npm/README.md | 1 + src/winapp-npm/src/cli.ts | 2 +- .../src/jsbindings/codegen-runner.ts | 2 +- .../jsbindings/package-manager-detector.ts | 20 +++++- src/winapp-npm/src/jsbindings/path-safety.ts | 46 ++++++++---- src/winapp-npm/src/jsbindings/winmd-policy.ts | 71 ------------------- .../test/package-manager-detector.test.ts | 19 +++++ src/winapp-npm/test/path-safety.test.ts | 40 ++++++++++- 14 files changed, 133 insertions(+), 118 deletions(-) diff --git a/.github/plugin/skills/winapp-cli/frameworks/SKILL.md b/.github/plugin/skills/winapp-cli/frameworks/SKILL.md index cde43d7d..4bccec6b 100644 --- a/.github/plugin/skills/winapp-cli/frameworks/SKILL.md +++ b/.github/plugin/skills/winapp-cli/frameworks/SKILL.md @@ -1,6 +1,6 @@ --- name: winapp-frameworks -description: Framework-specific Windows development guidance for Electron, .NET (WPF, WinForms), C++, Rust, Flutter, and Tauri, including JS bindings for WinRT APIs and native addons. Use when packaging or adding Windows features to an Electron app, .NET desktop app, Flutter app, Tauri app, Rust app, or C++ app. +description: Framework-specific Windows development guidance for Electron, .NET (WPF, WinForms), C++, Rust, Flutter, and Tauri, including JS bindings for Windows App SDK APIs and native addons. Use when packaging or adding Windows features to an Electron app, .NET desktop app, Flutter app, Tauri app, Rust app, or C++ app. version: 0.3.2 --- ## When to use @@ -30,29 +30,25 @@ Use the **npm package** (`@Microsoft/WinAppCli`), **not** the standalone CLI. Th - The native winapp CLI binary bundled inside `node_modules` - A Node.js SDK with helpers for creating native C#/C++ addons - Electron-specific commands under `npx winapp node` -- Commands for generating JS bindings for Windows App SDK APIs (no native build required) +- JS bindings for calling Windows App SDK APIs directly from JavaScript — no native addon required Quick start: ```powershell npm install --save-dev @microsoft/winappcli -npx winapp init . --use-defaults # fresh init: scaffolds winapp.yaml + JS bindings + C++ projections -npx winapp node create-addon --template cs # create a C# native addon (for what the JS bindings can't drive — see below) -npx winapp node add-electron-debug-identity # register identity for debugging +npx winapp init . --use-defaults +npx winapp node create-addon --template cs # create a C# native addon +npx winapp node add-electron-debug-identity # register identity for debugging ``` #### Choosing between JS bindings and a native addon -The decision is about the **shape of the API**, not preference. - -**Default — WinRT API (ships in a `.winmd`) → JS bindings.** Covers most of `Microsoft.Windows.*` (Notifications, FilePickers, Sensors, AI like `TextRecognizer` / `LanguageModel`), `Windows.*`, and `Microsoft.WindowsAppSDK.AI`. See [`@microsoft/dynwinrt` scope](https://github.com/microsoft/dynwinrt#scope). +**Default — Windows App SDK API → JS bindings.** You can call virtually all Windows App SDK APIs (Notifications, FilePickers, AI like `TextRecognizer` / `LanguageModel`, etc.) directly from JavaScript, excluding UI APIs (WinUI / XAML controls). See [`@microsoft/dynwinrt` scope](https://github.com/microsoft/dynwinrt#scope). **Fall back to `node create-addon` when there's no `.winmd`:** -| Scenario | Template | -|---|---| -| Win32 / pure COM (P/Invoke, raw `IFileDialog`, registry, custom COM servers) | `--template cpp` | -| C++ library (headers + static/shared lib only) | `--template cpp` | -| Managed .NET assembly only (vendor SDK) | `--template cs` ([node-api-dotnet](https://github.com/microsoft/node-api-dotnet)) | +- **Win32 / pure COM** (P/Invoke, raw `IFileDialog`, registry, custom COM servers) → `--template cpp` +- **C++ library** (headers + static/shared lib only) → `--template cpp` +- **Managed .NET assembly only** (vendor SDK) → `--template cs` ([node-api-dotnet](https://github.com/microsoft/node-api-dotnet)) Mixing both in one app is normal. diff --git a/.github/plugin/skills/winapp-cli/setup/SKILL.md b/.github/plugin/skills/winapp-cli/setup/SKILL.md index 842d8a6c..d9d32ccd 100644 --- a/.github/plugin/skills/winapp-cli/setup/SKILL.md +++ b/.github/plugin/skills/winapp-cli/setup/SKILL.md @@ -59,7 +59,7 @@ After `init`, your project will contain: - `winapp.yaml` — SDK version pinning for `restore`/`update` - `.winapp/` — downloaded SDK packages and generated projections - `.gitignore` update — excludes `.winapp/` and `devcert.pfx` -- `.winapp/bindings/` — generated JS bindings for WinRT APIs (npm-only, Node / Electron) +- `.winapp/bindings/` — generated JS bindings for Windows App SDK APIs (npm-only, Node / Electron) - `package.json` update — adds the `winapp.jsBindings` namespace and `@microsoft/dynwinrt` dependency (npm-only) ### Restore after cloning diff --git a/docs/fragments/skills/winapp-cli/frameworks.md b/docs/fragments/skills/winapp-cli/frameworks.md index 39903689..d04af83a 100644 --- a/docs/fragments/skills/winapp-cli/frameworks.md +++ b/docs/fragments/skills/winapp-cli/frameworks.md @@ -25,29 +25,25 @@ Use the **npm package** (`@Microsoft/WinAppCli`), **not** the standalone CLI. Th - The native winapp CLI binary bundled inside `node_modules` - A Node.js SDK with helpers for creating native C#/C++ addons - Electron-specific commands under `npx winapp node` -- Commands for generating JS bindings for Windows App SDK APIs (no native build required) +- JS bindings for calling Windows App SDK APIs directly from JavaScript — no native addon required Quick start: ```powershell npm install --save-dev @microsoft/winappcli -npx winapp init . --use-defaults # fresh init: scaffolds winapp.yaml + JS bindings + C++ projections -npx winapp node create-addon --template cs # create a C# native addon (for what the JS bindings can't drive — see below) -npx winapp node add-electron-debug-identity # register identity for debugging +npx winapp init . --use-defaults +npx winapp node create-addon --template cs # create a C# native addon +npx winapp node add-electron-debug-identity # register identity for debugging ``` #### Choosing between JS bindings and a native addon -The decision is about the **shape of the API**, not preference. - -**Default — WinRT API (ships in a `.winmd`) → JS bindings.** Covers most of `Microsoft.Windows.*` (Notifications, FilePickers, Sensors, AI like `TextRecognizer` / `LanguageModel`), `Windows.*`, and `Microsoft.WindowsAppSDK.AI`. See [`@microsoft/dynwinrt` scope](https://github.com/microsoft/dynwinrt#scope). +**Default — Windows App SDK API → JS bindings.** You can call virtually all Windows App SDK APIs (Notifications, FilePickers, AI like `TextRecognizer` / `LanguageModel`, etc.) directly from JavaScript, excluding UI APIs (WinUI / XAML controls). See [`@microsoft/dynwinrt` scope](https://github.com/microsoft/dynwinrt#scope). **Fall back to `node create-addon` when there's no `.winmd`:** -| Scenario | Template | -|---|---| -| Win32 / pure COM (P/Invoke, raw `IFileDialog`, registry, custom COM servers) | `--template cpp` | -| C++ library (headers + static/shared lib only) | `--template cpp` | -| Managed .NET assembly only (vendor SDK) | `--template cs` ([node-api-dotnet](https://github.com/microsoft/node-api-dotnet)) | +- **Win32 / pure COM** (P/Invoke, raw `IFileDialog`, registry, custom COM servers) → `--template cpp` +- **C++ library** (headers + static/shared lib only) → `--template cpp` +- **Managed .NET assembly only** (vendor SDK) → `--template cs` ([node-api-dotnet](https://github.com/microsoft/node-api-dotnet)) Mixing both in one app is normal. diff --git a/docs/fragments/skills/winapp-cli/setup.md b/docs/fragments/skills/winapp-cli/setup.md index bedaccd1..edaa2d0c 100644 --- a/docs/fragments/skills/winapp-cli/setup.md +++ b/docs/fragments/skills/winapp-cli/setup.md @@ -54,7 +54,7 @@ After `init`, your project will contain: - `winapp.yaml` — SDK version pinning for `restore`/`update` - `.winapp/` — downloaded SDK packages and generated projections - `.gitignore` update — excludes `.winapp/` and `devcert.pfx` -- `.winapp/bindings/` — generated JS bindings for WinRT APIs (npm-only, Node / Electron) +- `.winapp/bindings/` — generated JS bindings for Windows App SDK APIs (npm-only, Node / Electron) - `package.json` update — adds the `winapp.jsBindings` namespace and `@microsoft/dynwinrt` dependency (npm-only) ### Restore after cloning diff --git a/docs/usage.md b/docs/usage.md index cf9d2564..dcf4a6b4 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -61,7 +61,7 @@ When `init` is run without a directory argument, it performs a breadth-first sea The search skips commonly ignored directories (node_modules, bin, obj, .git, etc.). When a compatible project is found, subdirectories below it are not searched. - If a directory argument is provided (e.g., `winapp init .` or `winapp init path/to/project`), the search is skipped and `init` checks only that directory for a compatible project -- If `--use-defaults` is set without a directory argument, `init` searches for projects and errors with the list of detected projects — pass an explicit directory to use non-interactive mode (e.g., `winapp init . --use-defaults`) +- If `--use-defaults` (or `--no-prompt`) is set without a directory argument, `init` skips the search and initializes the current directory non-interactively, warning first if no known project type is detected there (e.g., `winapp init --use-defaults`) - In non-interactive environments (piped stdin, CI, redirected input), `init` automatically uses `--use-defaults` behavior and emits a warning: `Non-interactive environment detected. Using default values.` - If the current directory is a compatible project, `init` proceeds immediately - If exactly one project is found elsewhere, you're prompted to confirm diff --git a/scripts/generate-llm-docs.ps1 b/scripts/generate-llm-docs.ps1 index f9c30473..48095004 100644 --- a/scripts/generate-llm-docs.ps1 +++ b/scripts/generate-llm-docs.ps1 @@ -247,7 +247,7 @@ $SkillDescriptions = @{ "signing" = "Create and manage code signing certificates for Windows apps and MSIX packages. Use when generating a certificate, signing a Windows app or installer, or fixing certificate trust issues." "manifest" = "Create and edit Windows app manifest files (Package.appxmanifest or appxmanifest.xml) that define app identity, capabilities, and visual assets, or generate new assets from existing images. Use when creating a Windows app manifest for any app type (GUI, console, CLI tool, service), adding Windows capabilities, generating new app icons and assets, or adding execution aliases, file associations, protocol handlers, or other app extensions." "troubleshoot" = "Diagnose and fix common Windows app packaging, signing, identity, and SDK errors. Use when encountering errors with MSIX packaging, certificate signing, Windows SDK setup, or app installation." - "frameworks" = "Framework-specific Windows development guidance for Electron, .NET (WPF, WinForms), C++, Rust, Flutter, and Tauri, including JS bindings for WinRT APIs and native addons. Use when packaging or adding Windows features to an Electron app, .NET desktop app, Flutter app, Tauri app, Rust app, or C++ app." + "frameworks" = "Framework-specific Windows development guidance for Electron, .NET (WPF, WinForms), C++, Rust, Flutter, and Tauri, including JS bindings for Windows App SDK APIs and native addons. Use when packaging or adding Windows features to an Electron app, .NET desktop app, Flutter app, Tauri app, Rust app, or C++ app." "ui-automation" = "Inspect and interact with running Windows app UIs from the command line using UI Automation (UIA). Use when an AI agent or developer needs to inspect a UI element tree, find controls, take screenshots, click buttons, read or set text, or verify UI state in a running Windows app. Works with any framework WinUI 3, WPF, WinForms, Win32, Electron." } diff --git a/src/winapp-npm/README.md b/src/winapp-npm/README.md index 2c60a1ab..2192a584 100644 --- a/src/winapp-npm/README.md +++ b/src/winapp-npm/README.md @@ -62,6 +62,7 @@ npx winapp --help **Node.js/Electron Specific:** - [`node create-addon`](https://github.com/microsoft/WinAppCli/blob/main/docs/usage.md#node-create-addon) - Generate native C# or C++ addons +- [`node generate-bindings`](https://github.com/microsoft/WinAppCli/blob/main/docs/usage.md#node-generate-bindings) - Regenerate JS bindings for Windows App SDK APIs after editing `winapp.jsBindings` - [`node add-electron-debug-identity`](https://github.com/microsoft/WinAppCli/blob/main/docs/usage.md#node-add-electron-debug-identity) - Add identity to Electron processes The full CLI usage can be found here: [Documentation](https://github.com/microsoft/WinAppCli/blob/main/docs/usage.md) diff --git a/src/winapp-npm/src/cli.ts b/src/winapp-npm/src/cli.ts index b85507c8..921c0ef2 100644 --- a/src/winapp-npm/src/cli.ts +++ b/src/winapp-npm/src/cli.ts @@ -664,7 +664,7 @@ async function handleGenerateBindings(args: string[]): Promise { // 4. Hand off to the shared pipeline. Outcomes are translated to ✅ / ❌ /⚠️ // by runJsBindingsOrchestrator. - await runJsBindingsOrchestrator(workspaceDir, isVerbose(args), quiet, resolveYamlPath(args)); + await runJsBindingsOrchestrator(workspaceDir, isVerbose(args), quiet, resolveYamlPath(args, workspaceDir)); } /** diff --git a/src/winapp-npm/src/jsbindings/codegen-runner.ts b/src/winapp-npm/src/jsbindings/codegen-runner.ts index 68c79b8c..75274cf6 100644 --- a/src/winapp-npm/src/jsbindings/codegen-runner.ts +++ b/src/winapp-npm/src/jsbindings/codegen-runner.ts @@ -571,7 +571,7 @@ function isAcceptableNodeExe(candidate: string): boolean { if (!fs.existsSync(resolved)) { return false; } - const driveRoot = path.parse(resolved).root.replace(/[\\/]+$/, ''); + const driveRoot = path.parse(resolved).root; if (driveRoot && hasReparsePointOnPath(resolved, driveRoot)) { return false; } diff --git a/src/winapp-npm/src/jsbindings/package-manager-detector.ts b/src/winapp-npm/src/jsbindings/package-manager-detector.ts index 84756e12..b5313200 100644 --- a/src/winapp-npm/src/jsbindings/package-manager-detector.ts +++ b/src/winapp-npm/src/jsbindings/package-manager-detector.ts @@ -77,12 +77,28 @@ export function resolvePackageManagerPath(name: PackageManagerName): string | nu : ['']; for (const dir of dirs) { + // SECURITY: skip non-absolute PATH entries (`.`, `tools`, …). A relative + // entry would be joined to a relative candidate that `fs.statSync` resolves + // against `process.cwd()`, and the resulting relative path handed to the + // installer (which runs with `cwd: workspaceDir`) would resolve a + // workspace-controlled shim — the very CWE-426 hijack this function exists + // to prevent. Only absolute PATH directories are trusted. + if (!path.isAbsolute(dir)) { + continue; + } for (const ext of exts) { const candidate = path.join(dir, `${name}${ext}`); try { - if (fs.statSync(candidate).isFile()) { - return candidate; + if (!fs.statSync(candidate).isFile()) { + continue; + } + // Collapse any symlink/junction to its real location so the spawned + // absolute path can't be redirected back into an untrusted directory. + const real = fs.realpathSync.native(candidate); + if (!path.isAbsolute(real)) { + continue; } + return real; } catch { // Not present / not accessible — keep scanning. } diff --git a/src/winapp-npm/src/jsbindings/path-safety.ts b/src/winapp-npm/src/jsbindings/path-safety.ts index 033acb0c..2b05a45d 100644 --- a/src/winapp-npm/src/jsbindings/path-safety.ts +++ b/src/winapp-npm/src/jsbindings/path-safety.ts @@ -52,36 +52,43 @@ export function hasReparsePointOnPath(targetPath: string, boundary: string): boo return true; } - // Resolve both paths to absolute, normalized form for containment check. + // Resolve both paths to absolute form, then normalize for containment + // (trim trailing separators but preserve the root separator on bare drive + // designators — see normalizeForContainment). let absTarget: string; let absBoundary: string; try { - absTarget = path.resolve(targetPath); - absBoundary = path.resolve(boundary).replace(/[\\/]+$/, ''); + absTarget = normalizeForContainment(path.resolve(targetPath)); + absBoundary = normalizeForContainment(path.resolve(boundary)); } catch { // If we can't even resolve the paths, treat as unsafe — safer default. return true; } - const sep = path.sep; - const boundaryWithSep = absBoundary + sep; - - // Containment: target must equal boundary OR be a descendant. + // String-only containment. Boundary itself is a valid target; otherwise the + // target must live under boundary + a separator. const sameAsBoundary = absTarget.toLowerCase() === absBoundary.toLowerCase(); + const boundaryWithSep = absBoundary.endsWith(path.sep) ? absBoundary : absBoundary + path.sep; const insideBoundary = absTarget.toLowerCase().startsWith(boundaryWithSep.toLowerCase()); if (!sameAsBoundary && !insideBoundary) { return true; } + // Check the boundary itself first — a reparse-point boundary would make + // every descendant probe silently follow it. + if (isReparseSegment(absBoundary)) { + return true; + } + if (sameAsBoundary) { + return false; + } + // Walk DOWN from boundary to target, checking each existing segment's // attributes via lstat (does NOT follow symlinks). - const rel = sameAsBoundary ? '' : absTarget.substring(boundaryWithSep.length); + const rel = absTarget.substring(absBoundary.length); const segments = rel.length === 0 ? [] : rel.split(/[\\/]/).filter((s) => s.length > 0); let probe = absBoundary; - if (isReparseSegment(probe)) { - return true; - } for (const seg of segments) { probe = path.join(probe, seg); if (isReparseSegment(probe)) { @@ -91,6 +98,19 @@ export function hasReparsePointOnPath(targetPath: string, boundary: string): boo return false; } +// Trim trailing separators but preserve the root separator on bare drive +// designators. `C:\` collapsed to `C:` would make `path.join` produce +// drive-relative paths (`C:foo` → resolved against the per-drive CWD), +// silently bypassing the reparse-point check. Mirrors the native +// `PathSafety.NormalizeForContainment`. +function normalizeForContainment(p: string): string { + const trimmed = p.replace(/[\\/]+$/, ''); + if (trimmed.length === 2 && trimmed[1] === ':') { + return trimmed + path.sep; + } + return trimmed; +} + function isReparseSegment(p: string): boolean { let stat: fs.Stats; try { @@ -157,8 +177,8 @@ export function assertSafeWorkspaceOutputDir(workspaceDir: string, outputDir: st } const resolvedOutput = path.isAbsolute(outputDir) ? path.resolve(outputDir) : path.resolve(workspaceDir, outputDir); - const resolvedWorkspace = path.resolve(workspaceDir).replace(/[\\/]+$/, ''); - const prefix = resolvedWorkspace + path.sep; + const resolvedWorkspace = normalizeForContainment(path.resolve(workspaceDir)); + const prefix = resolvedWorkspace.endsWith(path.sep) ? resolvedWorkspace : resolvedWorkspace + path.sep; const insideWorkspace = resolvedOutput.length > prefix.length && resolvedOutput.toLowerCase().startsWith(prefix.toLowerCase()); diff --git a/src/winapp-npm/src/jsbindings/winmd-policy.ts b/src/winapp-npm/src/jsbindings/winmd-policy.ts index ccba9887..d8165319 100644 --- a/src/winapp-npm/src/jsbindings/winmd-policy.ts +++ b/src/winapp-npm/src/jsbindings/winmd-policy.ts @@ -10,8 +10,6 @@ // only writes the raw NuGet inventory and the npm wrapper applies policy // at codegen time. -import * as path from 'path'; - export type WinmdPackageCategory = 'emit' | 'refOnly' | 'skip'; // Built-in denylists. User `winapp.jsBindings` overrides layer on top. @@ -47,40 +45,6 @@ export function classifyPackage(packageId: string): WinmdPackageCategory { return 'emit'; } -// Given a winmd file path and the NuGet cache root, return the package ID -// (lowercased) by extracting the first path segment under the cache root. -// Returns null when the path is not under the cache (e.g. user winmds). -export function extractPackageIdFromPath(winmdPath: string, nugetCacheRoot?: string): string | null { - if (!winmdPath || !winmdPath.trim()) { - return null; - } - - if (nugetCacheRoot && nugetCacheRoot.trim()) { - try { - const full = path.resolve(winmdPath); - const root = path.resolve(nugetCacheRoot).replace(/[\\/]+$/, ''); - const rootPrefix = root + path.sep; - // Case-insensitive compare for Windows path conventions. - if (full.toLowerCase().startsWith(rootPrefix.toLowerCase())) { - const rel = full.substring(rootPrefix.length); - const firstSep = rel.search(/[\\/]/); - return firstSep > 0 ? rel.substring(0, firstSep) : rel; - } - } catch { - // Fall through to legacy heuristic. - } - } - - // Legacy heuristic: scan for a literal "packages" segment. - const segs = winmdPath.split(/[\\/]/).filter((s) => s.length > 0); - for (let i = 0; i < segs.length - 1; i++) { - if (segs[i].toLowerCase() === 'packages') { - return segs[i + 1]; - } - } - return null; -} - export interface WinmdPartition { emit: string[]; refOnly: string[]; @@ -97,9 +61,6 @@ export interface PackageWinmds { * Partition a list of `{name, winmds[]}` tuples by category, using the * package name directly (no path extraction needed — the lockfile already * groups winmds by package on the writer side). - * - * Prefer this overload over `partitionByPackageCategory(string[], …)` when - * the source data is the lockfile — see orchestrator.ts. */ export function partitionPackageWinmds(packages: readonly PackageWinmds[]): WinmdPartition { const emit: string[] = []; @@ -119,35 +80,3 @@ export function partitionPackageWinmds(packages: readonly PackageWinmds[]): Winm return { emit, refOnly, skipped }; } - -// Partition a flat list of winmd paths by category. Falls back to -// `extractPackageIdFromPath` for each entry — needed for loose user-supplied -// winmds that don't carry their package identity. For lockfile-sourced -// winmds, use `partitionPackageWinmds` instead. -export function partitionByPackageCategory( - winmds: readonly string[], - options?: { - nugetCacheRoot?: string; - } -): WinmdPartition { - const nugetCacheRoot = options?.nugetCacheRoot; - - const emit: string[] = []; - const refOnly: string[] = []; - const skipped: string[] = []; - - for (const w of winmds) { - const pkg = extractPackageIdFromPath(w, nugetCacheRoot); - const cat: WinmdPackageCategory = pkg === null ? 'emit' : classifyPackage(pkg); - - if (cat === 'skip') { - skipped.push(w); - } else if (cat === 'refOnly') { - refOnly.push(w); - } else { - emit.push(w); - } - } - - return { emit, refOnly, skipped }; -} diff --git a/src/winapp-npm/test/package-manager-detector.test.ts b/src/winapp-npm/test/package-manager-detector.test.ts index ac6f2a9b..14e07504 100644 --- a/src/winapp-npm/test/package-manager-detector.test.ts +++ b/src/winapp-npm/test/package-manager-detector.test.ts @@ -157,3 +157,22 @@ test('resolvePackageManagerPath ignores a launcher in the current directory (shi fs.rmSync(emptyPathDir, { recursive: true, force: true }); } }); + +test('resolvePackageManagerPath skips relative PATH entries (workspace-shim defense)', () => { + // A relative PATH entry (".", "tools", …) would join to a relative candidate + // that fs.statSync resolves against process.cwd(); the resulting relative + // path, handed to the installer running with cwd=workspaceDir, would resolve + // a workspace-controlled shim (CWE-426). Only absolute PATH dirs are trusted. + const cwdDir = fs.mkdtempSync(path.join(os.tmpdir(), 'winapp-which-rel-')); + const savedCwd = process.cwd(); + try { + fs.writeFileSync(path.join(cwdDir, `npm${launcherExt}`), ''); + process.chdir(cwdDir); + withEnv({ PATH: '.', PATHEXT: '.COM;.EXE;.BAT;.CMD' }, () => { + assert.equal(resolvePackageManagerPath('npm'), null); + }); + } finally { + process.chdir(savedCwd); + fs.rmSync(cwdDir, { recursive: true, force: true }); + } +}); diff --git a/src/winapp-npm/test/path-safety.test.ts b/src/winapp-npm/test/path-safety.test.ts index dbcc9c8c..5ef0262d 100644 --- a/src/winapp-npm/test/path-safety.test.ts +++ b/src/winapp-npm/test/path-safety.test.ts @@ -3,8 +3,11 @@ import { test } from 'node:test'; import * as assert from 'node:assert/strict'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; -import { isNetworkPath } from '../src/jsbindings/path-safety'; +import { isNetworkPath, hasReparsePointOnPath } from '../src/jsbindings/path-safety'; test('isNetworkPath returns false for empty and local drive-letter paths', () => { assert.equal(isNetworkPath(''), false); @@ -32,3 +35,38 @@ test('isNetworkPath flags DOS-device UNC paths (\\\\?\\UNC\\ and \\\\.\\UNC\\)', // Case-insensitive on the UNC token. assert.equal(isNetworkPath('\\\\?\\unc\\server\\share'), true); }); + +test('hasReparsePointOnPath accepts an absolute path contained under its drive root (regression: C:\\ boundary)', () => { + // Before the drive-root normalization fix, a drive-root boundary collapsed + // to a bare `C:` whose `path.resolve()` yields the per-drive CWD, so a + // legitimate same-drive absolute path was wrongly reported as "outside + // boundary". The temp dir is a normal directory under the drive root. + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'winapp-ps-')); + try { + const root = path.parse(dir).root; // e.g. "C:\\" or "/" + assert.equal(hasReparsePointOnPath(dir, root), false); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('hasReparsePointOnPath flags a target outside the boundary', () => { + const a = fs.mkdtempSync(path.join(os.tmpdir(), 'winapp-ps-a-')); + const b = fs.mkdtempSync(path.join(os.tmpdir(), 'winapp-ps-b-')); + try { + // b is a sibling of a, not contained under it. + assert.equal(hasReparsePointOnPath(b, a), true); + } finally { + fs.rmSync(a, { recursive: true, force: true }); + fs.rmSync(b, { recursive: true, force: true }); + } +}); + +test('hasReparsePointOnPath treats the boundary itself as contained', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'winapp-ps-same-')); + try { + assert.equal(hasReparsePointOnPath(dir, dir), false); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); From 00982608002f8d4b65322eaf8dff88b8fbcc2271 Mon Sep 17 00:00:00 2001 From: Leilei Zhang Date: Thu, 4 Jun 2026 20:10:58 +0800 Subject: [PATCH 27/27] update the framework skill description --- .github/plugin/skills/winapp-cli/frameworks/SKILL.md | 2 +- scripts/generate-llm-docs.ps1 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/plugin/skills/winapp-cli/frameworks/SKILL.md b/.github/plugin/skills/winapp-cli/frameworks/SKILL.md index c89e55fb..5ece1140 100644 --- a/.github/plugin/skills/winapp-cli/frameworks/SKILL.md +++ b/.github/plugin/skills/winapp-cli/frameworks/SKILL.md @@ -1,6 +1,6 @@ --- name: winapp-frameworks -description: Framework-specific Windows development guidance for Electron, .NET (WPF, WinForms), C++, Rust, Flutter, and Tauri, including JS bindings for Windows App SDK APIs and native addons. Use when packaging or adding Windows features to an Electron app, .NET desktop app, Flutter app, Tauri app, Rust app, or C++ app. +description: Framework-specific Windows development guidance for Electron, .NET (WPF, WinForms), C++, Rust, Flutter, and Tauri. Use when packaging or adding Windows features to an Electron app, .NET desktop app, Flutter app, Tauri app, Rust app, or C++ app. version: 0.3.3 --- ## When to use diff --git a/scripts/generate-llm-docs.ps1 b/scripts/generate-llm-docs.ps1 index 48095004..eb87c8f3 100644 --- a/scripts/generate-llm-docs.ps1 +++ b/scripts/generate-llm-docs.ps1 @@ -247,7 +247,7 @@ $SkillDescriptions = @{ "signing" = "Create and manage code signing certificates for Windows apps and MSIX packages. Use when generating a certificate, signing a Windows app or installer, or fixing certificate trust issues." "manifest" = "Create and edit Windows app manifest files (Package.appxmanifest or appxmanifest.xml) that define app identity, capabilities, and visual assets, or generate new assets from existing images. Use when creating a Windows app manifest for any app type (GUI, console, CLI tool, service), adding Windows capabilities, generating new app icons and assets, or adding execution aliases, file associations, protocol handlers, or other app extensions." "troubleshoot" = "Diagnose and fix common Windows app packaging, signing, identity, and SDK errors. Use when encountering errors with MSIX packaging, certificate signing, Windows SDK setup, or app installation." - "frameworks" = "Framework-specific Windows development guidance for Electron, .NET (WPF, WinForms), C++, Rust, Flutter, and Tauri, including JS bindings for Windows App SDK APIs and native addons. Use when packaging or adding Windows features to an Electron app, .NET desktop app, Flutter app, Tauri app, Rust app, or C++ app." + "frameworks" = "Framework-specific Windows development guidance for Electron, .NET (WPF, WinForms), C++, Rust, Flutter, and Tauri. Use when packaging or adding Windows features to an Electron app, .NET desktop app, Flutter app, Tauri app, Rust app, or C++ app." "ui-automation" = "Inspect and interact with running Windows app UIs from the command line using UI Automation (UIA). Use when an AI agent or developer needs to inspect a UI element tree, find controls, take screenshots, click buttons, read or set text, or verify UI state in a running Windows app. Works with any framework WinUI 3, WPF, WinForms, Win32, Electron." }