diff --git a/.github/plugin/agents/winapp.agent.md b/.github/plugin/agents/winapp.agent.md index 75fa482a..ed2a06b2 100644 --- a/.github/plugin/agents/winapp.agent.md +++ b/.github/plugin/agents/winapp.agent.md @@ -222,12 +222,16 @@ 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:** 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-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 5b69b7b0..5ece1140 100644 --- a/.github/plugin/skills/winapp-cli/frameworks/SKILL.md +++ b/.github/plugin/skills/winapp-cli/frameworks/SKILL.md @@ -30,6 +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` +- JS bindings for calling Windows App SDK APIs directly from JavaScript — no native addon required Quick start: ```powershell @@ -39,7 +40,20 @@ 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 + +**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`:** + +- **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. + Additional Electron guides: +- [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 00c0e65b..ddaca0ea 100644 --- a/.github/plugin/skills/winapp-cli/setup/SKILL.md +++ b/.github/plugin/skills/winapp-cli/setup/SKILL.md @@ -59,6 +59,8 @@ 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 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/.github/workflows/test-samples.yml b/.github/workflows/test-samples.yml index 2773755b..4e7d3a28 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 5591c5a6..d04af83a 100644 --- a/docs/fragments/skills/winapp-cli/frameworks.md +++ b/docs/fragments/skills/winapp-cli/frameworks.md @@ -25,6 +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` +- JS bindings for calling Windows App SDK APIs directly from JavaScript — no native addon required Quick start: ```powershell @@ -34,7 +35,20 @@ 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 + +**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`:** + +- **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. + Additional Electron guides: +- [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 d2a40d17..edaa2d0c 100644 --- a/docs/fragments/skills/winapp-cli/setup.md +++ b/docs/fragments/skills/winapp-cli/setup.md @@ -54,6 +54,8 @@ 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 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/guides/electron/index.md b/docs/guides/electron/index.md index b8d33581..222c2dd5 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 bindings](js-file-picker.md) ✨ *new* + +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-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`, 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. [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](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-file-picker.md b/docs/guides/electron/js-file-picker.md new file mode 100644 index 00000000..b6dc8e68 --- /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) + +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 +{ + "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 6b6d52a5..dcf4a6b4 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -45,6 +45,7 @@ winapp init [base-directory] [options] - 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 project detection:** @@ -60,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 @@ -870,6 +871,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/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/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/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 7f13dbaa..47e9d860 100644 --- a/samples/electron/test.Tests.ps1 +++ b/samples/electron/test.Tests.ps1 @@ -111,7 +111,7 @@ Describe "Electron Sample" { } finally { Pop-Location } } - It "Should initialize winapp workspace" -Skip:$script:skip { + It "Should initialize winapp workspace with JS bindings and C++ projections" -Skip:$script:skip { Push-Location $script:appDir try { Invoke-WinappCommand -Arguments "init . --use-defaults --setup-sdks=stable" @@ -124,6 +124,97 @@ Describe "Electron Sample" { Join-Path $script:appDir "Package.appxmanifest" | Should -Exist } + # ── JS bindings smoke (v2.x) ───────────────────────────────────── + + 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 + # "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 { + $pkgPath = Join-Path $script:appDir "package.json" + $pkg = Get-Content $pkgPath -Raw | ConvertFrom-Json + $pkg.dependencies.'@microsoft/dynwinrt' | Should -Not -BeNullOrEmpty ` + -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 { + $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 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 ".winapp\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 } + + $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" + } + + It "Should regenerate bindings via 'winapp node generate-bindings' (codegen-only path)" -Skip:$script:skip { + $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 + } + 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 { @@ -252,5 +343,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/src/winapp-CLI/WinApp.Cli.Tests/PathSafetyTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/PathSafetyTests.cs new file mode 100644 index 00000000..cde91b1d --- /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 / 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 +// 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.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)); + 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 */ } + } + } + + // --------------------------------------------------------------------- + // AtomicWriteAllTextAsync (round-4 M3) + // --------------------------------------------------------------------- + + [TestMethod] + public async Task AtomicWriteAllTextAsync_NewFile_WritesContentsAndLeavesNoTempBehind() + { + var target = Path.Combine(_tempDir.FullName, "out.yaml"); + const string contents = "key: value\n"; + + 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)); + // 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 async Task AtomicWriteAllTextAsync_ExistingFile_OverwritesContents() + { + var target = Path.Combine(_tempDir.FullName, "existing.yaml"); + File.WriteAllText(target, "old contents"); + + await PathSafety.AtomicWriteAllTextAsync(target, "new contents", System.Text.Encoding.UTF8); + + Assert.AreEqual("new contents", File.ReadAllText(target)); + } + + [TestMethod] + public async Task AtomicWriteAllTextAsync_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"); + + 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 + // 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/WinmdsLockfileServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/WinmdsLockfileServiceTests.cs new file mode 100644 index 00000000..0b7997db --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/WinmdsLockfileServiceTests.cs @@ -0,0 +1,333 @@ +// 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"); + // 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")); + 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\": 3"); + StringAssert.Contains(json, "\"generated_at\""); + StringAssert.Contains(json, "Microsoft.WindowsAppSDK.AI"); + 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( + 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_PreservesPackageVersionsAndWinmds() + { + var winapp = _temp.CreateSubdirectory("winapp"); + var cache = _temp.CreateSubdirectory("packages"); + + // 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"); + + 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(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(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(1, ie.Winmds.Count); + + var winui = lockfile.Packages.Single(p => p.Name == "Microsoft.WindowsAppSDK.WinUI"); + Assert.AreEqual(1, winui.Winmds.Count); + + var umbrella = lockfile.Packages.Single(p => p.Name == "Microsoft.WindowsAppSDK"); + 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."); + } + + // ------------------------------------------------------------------------- + // 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_OlderSchemaVersions_ReturnNull() + { + // 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] + public async Task WriteAsync_UsesAtomicTempThenRename() + { + // 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"); + + 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); + } + + // --------------------------------------------------------------------- + // 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": 3, "generated_at": "2025-01-01T00:00:00Z", "packages": [] } + """); + + 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/YamlPackagesHasherTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/YamlPackagesHasherTests.cs new file mode 100644 index 00000000..829c592a --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/YamlPackagesHasherTests.cs @@ -0,0 +1,102 @@ +// 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)); + } + + [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-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs b/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs index 0b44b665..16aa766e 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs @@ -36,6 +36,7 @@ public static IServiceCollection ConfigureServices(this IServiceCollection servi .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 new file mode 100644 index 00000000..21ad17c8 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Helpers/PathSafety.cs @@ -0,0 +1,218 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.IO; + +namespace WinApp.Cli.Helpers; + +// 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`, if any segment + // from `boundary` down to `path` is a reparse point, or if either side is + // a UNC path. + // + // 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; + 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); + + // String-only containment. Boundary itself is a valid target; + // otherwise path must live under boundary + a separator. + 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 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)) + { + return true; + } + + if (isBoundaryItself) + { + return false; + } + + 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; + } + } + + return false; + } + + // True for UNC / network paths (`\\server\share`, `\\?\UNC\…`, + // `\\.\UNC\…`). Local DOS device paths (`\\?\C:\…`) are not network. + public static bool IsNetworkPath(string path) + { + if (string.IsNullOrEmpty(path)) + { + return false; + } + + var p = path.Replace('/', '\\'); + + // Plain UNC: \\server\share… + if (p.Length >= 3 + && p[0] == '\\' && p[1] == '\\' + && p[2] != '?' && p[2] != '.') + { + return true; + } + + // Device-prefixed UNC: \\?\UNC\… or \\.\UNC\… + 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 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); + 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 + { + attributes = default; + return false; + } + } + + // 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, + 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/Models/WinmdsLockfile.cs b/src/winapp-CLI/WinApp.Cli/Models/WinmdsLockfile.cs new file mode 100644 index 00000000..0afbc969 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Models/WinmdsLockfile.cs @@ -0,0 +1,63 @@ +// 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 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. + // + // 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; + + // 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. 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). + 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; + + // 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/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/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 new file mode 100644 index 00000000..bb62c764 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/WinmdsLockfileService.cs @@ -0,0 +1,178 @@ +// 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.Helpers; +using WinApp.Cli.Models; + +namespace WinApp.Cli.Services; + +// 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"; + + public FileInfo GetLockfilePath(DirectoryInfo winappDir) => + new(Path.Combine(winappDir.FullName, LockfileName)); + + // `.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 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); + } + + public async Task WriteAsync( + DirectoryInfo winappDir, + IReadOnlyDictionary usedVersions, + IReadOnlyList discoveredWinmds, + DirectoryInfo nugetCacheDir, + string? yamlPackagesHash = null, + CancellationToken cancellationToken = default) + { + try + { + var path = GetLockfilePath(winappDir); + if (IsLockfilePathUnsafe(winappDir, path)) + { + // 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); + return; + } + + winappDir.Create(); + var lockfile = BuildLockfile(usedVersions, discoveredWinmds, nugetCacheDir, yamlPackagesHash); + var json = JsonSerializer.Serialize(lockfile, WinmdsLockfileJsonContext.Default.WinmdsLockfile); + + // Shared helper owns staging + fsync + rename semantics. + await PathSafety.AtomicWriteAllTextAsync( + path.FullName, + json + "\n", + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), + cancellationToken); + + 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 optional. + logger.LogDebug(ex, "Failed to write winmds lockfile (continuing without)"); + } + } + + public async Task TryReadAsync( + DirectoryInfo winappDir, + CancellationToken cancellationToken = default) + { + 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); + return null; + } + + 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; 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; output keeps usedVersions casing. + var winmdsByPackage = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var w in discoveredWinmds) + { + var pkgIdLc = PackageLayoutService.TryGetPackageIdFromPath(nugetCacheDir, w.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); + packages.Add(new WinmdsLockfilePackage + { + Name = name, + Version = version, + 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..d67d65cf 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/WorkspaceSetupService.cs @@ -36,6 +36,7 @@ internal class WorkspaceSetupService( IBuildToolsService buildToolsService, ICppWinrtService cppWinrtService, IPackageLayoutService packageLayoutService, + IWinmdsLockfileService winmdsLockfileService, IPackageRegistrationService packageRegistrationService, INugetService nugetService, IManifestService manifestService, @@ -74,6 +75,16 @@ public async Task SetupWorkspaceAsync(WorkspaceSetupOptions options, Cancel return 1; } + // Restore on a non-.NET project with no winapp.yaml — nothing to restore. + // 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; @@ -560,6 +571,18 @@ await taskContext.AddSubTaskAsync("Configuring developer mode", async (taskConte return (2, "No .winmd files found for C++/WinRT projection."); } + // 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 + .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); 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..2192a584 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 +- [`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:** @@ -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/package-lock.json b/src/winapp-npm/package-lock.json index e23ab727..ce1b011c 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.5" + }, "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.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" + ], + "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..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/", @@ -51,6 +52,9 @@ "os": [ "win32" ], + "dependencies": { + "@microsoft/dynwinrt-codegen": "0.1.0-preview.5" + }, "devDependencies": { "@eslint/js": "^10.0.1", "@types/node": "^25.5.0", diff --git a/src/winapp-npm/src/cli-args.ts b/src/winapp-npm/src/cli-args.ts new file mode 100644 index 00000000..63b348e9 --- /dev/null +++ b/src/winapp-npm/src/cli-args.ts @@ -0,0 +1,131 @@ +// 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 `--no-install` — opt out of auto-installing the runtime dependency. */ +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 reads, so the + * orchestrator's staleness check (`yaml_packages_hash`) compares against the + * SAME file native used. Explicit `--config-dir` always wins: + * + * --config-dir / --config-dir=/winapp.yaml + * + * 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[], defaultConfigDir: string = process.cwd()): string { + const explicit = extractConfigDir(args); + const configDir = explicit ? path.resolve(explicit) : path.resolve(defaultConfigDir); + 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 b87e9851..921c0ef2 100644 --- a/src/winapp-npm/src/cli.ts +++ b/src/winapp-npm/src/cli.ts @@ -4,8 +4,24 @@ 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 { hasJsBindings, ensureJsBindingsBlock } from './jsbindings/package-json-config'; +import { runJsBindingsPipeline } from './jsbindings/orchestrator'; +import { getLockfilePath, LOCKFILE_NAME } from './jsbindings/lockfile-reader'; +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'; +import * as path from 'path'; // CLI name - change this to rebrand the tool const CLI_NAME = 'winapp'; @@ -13,6 +29,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 +89,41 @@ 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. + // + // 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') { + // 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); + return; + } + if (command === 'restore') { + await handleRestore(commandArgs); + return; + } + } + // Route everything else to winapp-cli await callWinappCli(args, { exitOnError: true }); } catch (error) { @@ -86,7 +145,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 +270,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 { @@ -267,9 +333,10 @@ 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(' 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 +344,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 +366,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 +576,340 @@ 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). + * + * 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 { + 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 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 output directory'); + console.log(''); + 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.'); + 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:'); + console.log(` ${CLI_NAME} node generate-bindings`); + console.log(` ${CLI_NAME} node generate-bindings --verbose`); + return; + } + + const workspaceDir = resolveWorkspaceDir(args); + const quiet = isQuiet(args); + + // 1. Must be an npm/Node project — winapp.jsBindings lives in 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 re-run this command.'); + process.exit(1); + } + + // 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`.) + const lockfilePath = getLockfilePath(workspaceDir); + assertSafeWorkspaceFile(workspaceDir, lockfilePath, LOCKFILE_NAME); + if (!fs.existsSync(lockfilePath)) { + console.error(`❌ No .winapp/${LOCKFILE_NAME} found.`); + 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, isVerbose(args), quiet, resolveYamlPath(args, workspaceDir)); +} + +/** + * 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 + * 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 = 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 = stripWrapperOnlyFlags(args); + 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({ + workspaceDir, + argv: args, + isInit: true, + existingJsBindings, + sdksReady: lockfilePresent || configOnly, + }); + } catch (err) { + logErrorAndExit(err); + } + + if (outcome.silentReason && !quiet) { + console.log(`ℹ️ ${outcome.silentReason}`); + } + + // User opted out — nothing more to do. + if (outcome.kind === 'no') { + return; + } + + // Persist the default jsBindings block to package.json so subsequent + // `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 { + 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; + } + ensureJsBindingsBlock(workspaceDir, { + reset: outcome.overwriteExistingConfig === true, + quiet, + }); + } catch (err) { + console.error(`Failed to update package.json: ${(err as Error).message}`); + process.exit(1); + } + + // --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. + // + // 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. + // + // 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 + ); +} + +/** + * `restore` intercept: run native restore unconditionally, then orchestrate + * dynwinrt-codegen iff package.json declares `winapp.jsBindings`. + */ +async function handleRestore(args: string[]): Promise { + 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; + } + + // 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; + } + + // 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. */ +async function runJsBindingsOrchestrator( + workspaceDir: string, + verbose: boolean = false, + quiet: boolean = false, + yamlPath?: string, + installRuntimeDep: boolean = false, + manageRuntimeDep: boolean = false +): Promise { + try { + const result = await runJsBindingsPipeline({ + workspaceDir, + verbose, + quiet, + yamlPath, + installRuntimeDep, + manageRuntimeDep, + }); + switch (result.outcome) { + case 'completed': + if (!quiet) { + 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': + // Warning surfaces even with --quiet so users see actionable signals. + 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/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/additional-winmds.ts b/src/winapp-npm/src/jsbindings/additional-winmds.ts new file mode 100644 index 00000000..82ed27d4 --- /dev/null +++ b/src/winapp-npm/src/jsbindings/additional-winmds.ts @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. +// +// 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'; + +/** + * Package.json entry; `winmdPath` alone bulk-emits, + * while namespace+classes cherry-picks. + */ +export interface AdditionalWinmd { + winmdPath?: string; + namespace?: string; + classes?: string[]; +} + +export interface ResolvedAdditionalWinmd { + /** Absolute path after UNC/reparse checks; undefined for auto-detect entries. */ + winmdPath?: string; + namespace?: string; + classes?: string[]; +} + +export interface ResolveAdditionalWinmdsResult { + resolved: ResolvedAdditionalWinmd[]; + warnings: string[]; +} + +export function resolveAdditionalWinmds( + entries: readonly AdditionalWinmd[] | undefined, + workspaceDir: string, + fieldName: string +): ResolveAdditionalWinmdsResult { + const resolved: ResolvedAdditionalWinmd[] = []; + const warnings: string[] = []; + if (!entries || entries.length === 0) { + return { resolved, warnings }; + } + + const seenIndex = new Map(); + const workspaceFull = path.resolve(workspaceDir).replace(/[\\/]+$/, ''); + + for (const entry of entries) { + if (!entry) { + continue; + } + + 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: ${rawPath}` + ); + continue; + } + + 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: ${rawPath} → ${fullPath}` + ); + continue; + } + + 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: ${rawPath} → ${fullPath}` + ); + continue; + } + + if (!fs.existsSync(fullPath)) { + warnings.push(`jsBindings.${fieldName} entry not found, skipping: ${rawPath} (resolved to ${fullPath})`); + continue; + } + + 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) { + out.namespace = ns; + out.classes = classes; + } + resolved.push(out); + } + + return { resolved, warnings }; +} + +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 new file mode 100644 index 00000000..75274cf6 --- /dev/null +++ b/src/winapp-npm/src/jsbindings/codegen-runner.ts @@ -0,0 +1,637 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. +// +// 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'; +import * as os from 'os'; +import * as crypto from 'crypto'; +import { spawn } from 'child_process'; +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. +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 { + /** Omit to rely on dynwinrt-codegen auto-detect (Windows SDK Windows.winmd). */ + winmdPath?: string; + namespace: string; + classes: readonly string[]; +} + +export interface CodegenInputs { + /** Winmds that generate bindings after policy filtering. */ + emitWinmds: readonly string[]; + /** Winmds loaded for type resolution only. */ + refWinmds: readonly string[]; + /** Per-class generation passes from cherry-picked additionalWinmds. */ + cherryPicks: readonly CodegenCherryPick[]; + workspaceDir: string; + /** Sink for stdout/stderr lines from the codegen child. */ + log?: (line: string) => void; + /** false buffers stdout until failure; true streams child output. */ + verbose?: boolean; +} + +export interface CodegenSummary { + classes: number; + interfaces: number; + enums: number; +} + +export interface CodegenResult { + outputDir: string; + /** Aggregated counts parsed from codegen stdout. */ + 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); + fs.mkdirSync(path.dirname(outputDir), { recursive: true }); + + const emit = dedupeCaseInsensitive(inputs.emitWinmds); + // 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()))); + + 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, refs); + const stdout = await spawnCodegen(executable, args, inputs.workspaceDir, log, verbose); + accumulateSummary(summary, parseSummary(stdout)); + } + for (const cp of inputs.cherryPicks) { + if (!cp.namespace.trim() || cp.classes.length === 0) { + continue; + } + const args = buildExtraTypeArgs(prefixArgs, emit, stagingDir, refs, cp); + const stdout = await spawnCodegen(executable, args, inputs.workspaceDir, log, verbose); + accumulateSummary(summary, parseSummary(stdout)); + } + }); + + return { outputDir, summary }; +} + +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. */ +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' }); +} + +// 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, + 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); + + ensureEsmPackageMarker(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 finally delete the now-renamed user 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) { + // Keep the backup so the user can recover manually. + 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 */ + } + } + } +} + +export function buildBulkArgs( + prefixArgs: readonly string[], + emitWinmds: readonly string[], + outputDir: string, + refWinmds: readonly string[] +): string[] { + const args: string[] = [ + ...prefixArgs, + 'generate', + '--winmd', + emitWinmds.join(';'), + '--output', + outputDir, + '--lang', + 'js', + ]; + if (refWinmds.length > 0) { + args.push('--ref', refWinmds.join(';')); + } + return args; +} + +export function buildExtraTypeArgs( + prefixArgs: readonly string[], + emitWinmds: readonly string[], + outputDir: string, + refWinmds: readonly string[], + extra: CodegenCherryPick +): string[] { + const args: string[] = [...prefixArgs, 'generate']; + // 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); + 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, + '--class-name', + extra.classes.join(','), + '--output', + outputDir, + '--lang', + 'js' + ); + + const refs = Array.from(refSet); + if (refs.length > 0) { + args.push('--ref', refs.join(';')); + } + return args; +} + +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) { + if (stdout) { + log(stdout); + } + if (stderr) { + log(stderr); + } + reject(new Error(`dynwinrt-codegen failed (exit ${code ?? 'null'}). See output above for details.`)); + return; + } + // Quiet success suppresses codegen's noisy per-file progress; use --verbose for details. + if (verbose) { + if (stdout) { + log(stdout); + } + if (stderr) { + log(stderr); + } + } + resolve(stdout); + }); + }); +} + +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; + } + // 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)) { + 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; +} + +interface CodegenInvocation { + executable: string; + prefixArgs: string[]; +} + +// 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(); + + 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)) { + return { executable: exePath, prefixArgs: [] }; + } + + // 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(); + 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 ${CODEGEN_PACKAGE_NAME} resolvable via Node module resolution.\n\n`; + + throw new Error( + partialHint + + '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` + + '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.' + ); +} + +/** + * 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(); + + const yieldUnique = function* (dir: string | null): Generator { + if (!dir) return; + const key = dir.toLowerCase(); + if (seen.has(key)) return; + seen.add(key); + yield dir; + }; + + // Node's resolver handles hoisted, isolated, and PnP package layouts. + yield* yieldUnique(resolveViaRequireResolve(wrapperDir)); + + // Physical walk preserves the legacy fallback for patched/bundled installs. + 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); + // Global installs still need to resolve the bundled codegen. + searchPaths.push(__dirname); + + try { + const pkgJson = require.resolve(`${CODEGEN_PACKAGE_NAME}/package.json`, { paths: searchPaths }); + // Reject PnP virtual `.zip!/` paths by requiring a real parent directory. + const pkgDir = path.dirname(pkgJson); + if (fs.existsSync(pkgDir)) { + return pkgDir; + } + } catch { + // require.resolve throws on no-match; treat as "not installed". + } + return null; +} + +function parentOrNull(dir: string): string | null { + const parent = path.dirname(dir); + return parent === dir ? null : parent; +} + +// Walk up from dist/src jsbindings until package.json names @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'; +} + +// Trust process.execPath first because npm selected it to load this wrapper. +// It still must be a native .exe/.com with no UNC or reparse-point ancestor. +// PATH fallback is safety-gated and excludes .bat/.cmd re-parsing. +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; + } + // Anchor on the drive root so arbitrary system paths are scanned for junctions. + // This covers paths like `C:\Program Files\nodejs\node.exe`, not just workspace paths. + 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; + if (driveRoot && hasReparsePointOnPath(resolved, driveRoot)) { + return false; + } + return true; +} + +// PATH fallback rejects relative/CWD/UNC/reparse-backed candidates and .bat/.cmd shims. +// That prevents an attacker-controlled PATH entry from running cli.js. +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; + } + if (isNetworkPath(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) && isAcceptableNodeExe(candidate)) { + return candidate; + } + } + const bare = path.join(resolvedDir, command); + if (fs.existsSync(bare) && isAcceptableNodeExe(bare)) { + 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..cefc2af5 --- /dev/null +++ b/src/winapp-npm/src/jsbindings/init-prompt.ts @@ -0,0 +1,203 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +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; + /** + * 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 { + kind: BindingsKind; + /** Reason the prompt was skipped; undefined when the prompt ran. */ + silentReason?: string; + /** Set only for existing config: true resets, false preserves. */ + overwriteExistingConfig?: boolean; +} + +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. + 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, + }; + } + + // Init re-runs mirror native overwrite prompts; default Yes preserves UX parity. + if (inputs.existingJsBindings) { + const isDotNet = detectDotNetProject(inputs.workspaceDir); + if (isDotNet) { + 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) { + // Non-interactive runs preserve existing config unless --use-defaults opts into 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) { + // dynwinrt bindings target Node/Electron; .NET already gets WinRT via CsWinRT. + return { + kind: 'no', + silentReason: + '.NET project detected — JS bindings target Node/Electron via dynwinrt; .NET projects already get WinRT via CsWinRT.', + }; + } + + // Without package.json we have nowhere to write `winapp.jsBindings` or 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.', + }; + } + + // JS bindings generate against the Windows App SDK winmds that SDK setup pulls + // down. If the user skipped SDK setup, there's nothing to generate against — so + // don't even ask. They can add bindings later once the SDKs are in place. + if (!inputs.sdksReady) { + return { + kind: 'no', + silentReason: + 'Windows SDKs were not set up during init, so JS bindings were skipped. ' + + 'Run `npx winapp restore` then `npx winapp node generate-bindings` to add them later.', + }; + } + + 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; +} + +// 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]; + 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 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 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); + + 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 { + // Keep retrying to match Spectre's refusal of unrecognized answers. + 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) { + continue; + } + + if (useColor) { + // 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`); + } + + 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..03a01dbf --- /dev/null +++ b/src/winapp-npm/src/jsbindings/lockfile-reader.ts @@ -0,0 +1,199 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. +// +// 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 3 dropped native-side JS binding categories; npm computes them now. +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[]; +} + +/** 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 null and a file existed or was unsafe. */ + reason?: string; +} + +/** + * 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 reparse/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 }; + } + + 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 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; + + 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 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; + 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 + : typeof obj.yamlPackagesHash === 'string' + ? obj.yamlPackagesHash + : undefined; + + const cacheBoundary = path.resolve(nugetCacheDir).replace(/[\\/]+$/, ''); + const droppedPaths: string[] = []; + 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 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; + } + 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) { + // Include examples so tampered lockfiles are actionable. + 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.', + }; + } + + // Best-effort check that .winapp wasn't swapped after the initial probe. + try { + assertSafeWorkspaceFile(workspaceDir, winappDir, '.winapp'); + } catch (err) { + return { lockfile: null, reason: (err as Error).message }; + } + + const lockfile: WinmdsLockfile = { + schemaVersion, + generatedAt, + nugetCacheDir, + yamlPackagesHash, + 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..7cf96669 --- /dev/null +++ b/src/winapp-npm/src/jsbindings/orchestrator.ts @@ -0,0 +1,290 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. +// +// 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'; +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 { detectPackageManager } from './package-manager-detector'; +import { installRuntimeDependency } from './runtime-installer'; +import { startSpinner, Spinner } from './spinner'; +import { computeYamlPackagesHash, readWinappYamlPackages } from './yaml-packages-hash'; + +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 when completed. */ + outputDir?: string; +} + +export interface OrchestratorOptions { + workspaceDir: string; + /** 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. */ + versionOverride?: string; + /** Sink for codegen progress lines; defaults to console. */ + log?: (line: string) => void; + /** Forward to codegen-runner; false suppresses per-file noise. */ + 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 { + const log = options.log ?? ((line) => console.log(line)); + const workspaceDir = path.resolve(options.workspaceDir); + + 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; + + const lockResult = tryReadLockfile(workspaceDir); + if (!lockResult.lockfile) { + return { + outcome: lockResult.reason?.includes('schema mismatch') ? 'lockfileStale' : 'lockfileMissing', + message: + 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; + + // If SDK pins changed after restore, codegen would emit against stale winmd inventory. + if (lockfile.yamlPackagesHash) { + const currentPackages = readWinappYamlPackages(workspaceDir, options.yamlPath); + 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.', + }; + } + } + + // 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 })), + workspaceDir, + 'additionalRefs' + ); + for (const w of [...userEmit.warnings, ...userRefs.warnings]) { + log(w); + } + + // Cherry-pick entries load as refs; only listed classes are emitted. + const bulkAdditional: 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({ + winmdPath: entry.winmdPath, + namespace: entry.namespace, + classes: entry.classes, + }); + } else if (entry.winmdPath) { + bulkAdditional.push(entry.winmdPath); + } + } + + // Built-in NuGet policy has no user overrides; additionalWinmds are explicit overrides. + const partition = partitionPackageWinmds(lockfile.packages); + + const emitWinmds = [...partition.emit, ...bulkAdditional]; + // 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 { + outcome: 'noWinmdsToEmit', + message: + '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`.', + }; + } + + // 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)` : '') + + `...`; + const useSpinner = !options.log && !options.verbose && !options.quiet; + let spinner: Spinner | null = null; + if (useSpinner) { + spinner = startSpinner(progressText); + } else if (!options.quiet) { + log(`🔨 ${progressText}`); + } + + let codegenResult; + try { + codegenResult = await runCodegen({ + emitWinmds, + refWinmds, + cherryPicks, + workspaceDir, + log, + verbose: options.verbose, + }); + } finally { + spinner?.stop(); + } + + // 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}`); + } + } + } 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 { + 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 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..37a7c616 --- /dev/null +++ b/src/winapp-npm/src/jsbindings/package-json-config.ts @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +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 { + // Extra .winmd files to feed into codegen, either bulk-emitted or cherry-picked. + additionalWinmds: AdditionalWinmd[]; + // Extra .winmd files loaded for type resolution only. + additionalRefs: string[]; +} + +export function defaultJsBindingsConfig(): JsBindingsConfig { + return { + additionalWinmds: [], + additionalRefs: [], + }; +} + +export interface ReadJsBindingsResult { + /** True when package.json existed and parsed successfully. */ + packageJsonExists: boolean; + /** Parsed config, or null when `winapp.jsBindings` isn't present. */ + jsBindings: JsBindingsConfig | null; +} + +/** Read package.json and return `winapp.jsBindings` when present. */ +export function readJsBindingsConfig(workspaceDir: string): ReadJsBindingsResult { + const doc = readPackageJsonDoc(workspaceDir); + if (!doc) { + return { packageJsonExists: false, jsBindings: null }; + } + 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) }; +} + +/** Propagates malformed package.json errors instead of silently skipping codegen. */ +export function hasJsBindings(workspaceDir: string): boolean { + return readJsBindingsConfig(workspaceDir).jsBindings !== null; +} + +export type EnsureJsBindingsOutcome = 'added' | 'reset' | 'unchanged'; + +export interface EnsureJsBindingsOptions { + /** Only reset existing user config after explicit opt-in. */ + reset?: boolean; + /** Suppress the informational banner printed to stdout. */ + quiet?: boolean; +} + +/** Ensure package.json declares `winapp.jsBindings`; explicit opt-in only, never restore. */ +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 `additionalWinmds` or `additionalRefs` to customize.' + ); + } + 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'; +} + +/** + * 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)) { + throw new Error( + `package.json not found in ${workspaceDir}. ` + + 'Run `npm init -y` (or equivalent) before adding JS bindings configuration.' + ); + } + 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) }; + }); +} + +/** Render the JSON-serializable config shape embedded in package.json. */ +export function renderJsBindingsConfig(config: JsBindingsConfig): unknown { + return serializeConfig(config); +} + +function coerceConfig(raw: unknown): JsBindingsConfig { + const defaults = defaultJsBindingsConfig(); + if (!raw || typeof raw !== 'object') { + return defaults; + } + const r = raw as Record; + + return { + additionalWinmds: coerceAdditionalWinmds(r.additionalWinmds), + additionalRefs: coerceStringArray(r.additionalRefs), + }; +} + +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 coerceAdditionalWinmds(value: unknown): AdditionalWinmd[] { + if (!Array.isArray(value)) { + return []; + } + 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() : ''; + const ns = typeof r.namespace === 'string' ? r.namespace.trim() : ''; + const classes = coerceStringArray(r.classes); + // 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; + } + out.push(entry); + } + return out; +} + +/** Stable key order; empty arrays remain explicit defaults in package.json. */ +function serializeConfig(config: JsBindingsConfig): Record { + return { + additionalWinmds: config.additionalWinmds.map((w) => { + 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]; + } + return entry; + }), + additionalRefs: [...config.additionalRefs], + }; +} + +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..6157f80b --- /dev/null +++ b/src/winapp-npm/src/jsbindings/package-json-doc.ts @@ -0,0 +1,203 @@ +// 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 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'; +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/package-manager-detector.ts b/src/winapp-npm/src/jsbindings/package-manager-detector.ts new file mode 100644 index 00000000..b5313200 --- /dev/null +++ b/src/winapp-npm/src/jsbindings/package-manager-detector.ts @@ -0,0 +1,181 @@ +// 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 type PackageManagerName = 'npm' | 'yarn' | 'pnpm' | 'bun'; + +export interface DetectedPackageManager { + 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'] }; + } +} + +/** + * 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) { + // 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()) { + 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. + } + } + } + return null; +} + +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..2b05a45d --- /dev/null +++ b/src/winapp-npm/src/jsbindings/path-safety.ts @@ -0,0 +1,203 @@ +// 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 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 = 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; + } + + // 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 = absTarget.substring(absBoundary.length); + const segments = rel.length === 0 ? [] : rel.split(/[\\/]/).filter((s) => s.length > 0); + + let probe = absBoundary; + for (const seg of segments) { + probe = path.join(probe, seg); + if (isReparseSegment(probe)) { + return true; + } + } + 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 { + 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(); +} + +/** + * 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 = 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()); + + 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 '.winapp/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 new file mode 100644 index 00000000..bd48befd --- /dev/null +++ b/src/winapp-npm/src/jsbindings/runtime-dep-injector.ts @@ -0,0 +1,227 @@ +// 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. 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). 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 { readPackageJsonDoc, mutatePackageJsonDoc } from './package-json-doc'; + +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'); + } + + // 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' }; + } + + const obj = doc.parsed; + const deps = obj.dependencies; + if (deps && typeof deps === 'object' && !Array.isArray(deps)) { + 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; + if (devDeps && typeof devDeps === 'object' && !Array.isArray(devDeps)) { + if (packageName in (devDeps as Record)) { + return { outcome: 'presentInDevDependencies' }; + } + } + + // 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)); + + 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, + 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; +} + +// 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 install a newly added dep locally. */ + 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 install it locally.`, + 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/runtime-installer.ts b/src/winapp-npm/src/jsbindings/runtime-installer.ts new file mode 100644 index 00000000..6e90ecf2 --- /dev/null +++ b/src/winapp-npm/src/jsbindings/runtime-installer.ts @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. +// +// 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. +// +// 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, SpawnSyncOptions } from 'child_process'; +import { PackageManagerName, buildAddExactCommand, resolvePackageManagerPath } 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; +} + +/** + * 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. 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, + packageName: string, + version: string, + pmName: PackageManagerName +): RuntimeInstallResult { + const spec = `${packageName}@${version}`; + const { exe, args } = buildAddExactCommand(pmName, spec); + const command = `${exe} ${args.join(' ')}`; + + 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'], + 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; + 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/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..d8165319 --- /dev/null +++ b/src/winapp-npm/src/jsbindings/winmd-policy.ts @@ -0,0 +1,82 @@ +// 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. + +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()) +); + +// 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: +// skip > refOnly > emit (default) +export function classifyPackage(packageId: string): WinmdPackageCategory { + if (!packageId || !packageId.trim()) { + return 'emit'; + } + const id = packageId.toLowerCase(); + if (DEFAULT_SKIPPED_PACKAGES.has(id)) { + return 'skip'; + } + if (DEFAULT_REF_ONLY_PACKAGES.has(id)) { + return 'refOnly'; + } + return 'emit'; +} + +export interface WinmdPartition { + emit: string[]; + refOnly: string[]; + skipped: string[]; +} + +/** 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). + */ +export function partitionPackageWinmds(packages: readonly PackageWinmds[]): WinmdPartition { + 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; + } + const cat: WinmdPackageCategory = classifyPackage(pkg.name); + const bucket = cat === 'skip' ? skipped : cat === 'refOnly' ? refOnly : emit; + for (const w of pkg.winmds) { + bucket.push(w); + } + } + + return { emit, refOnly, skipped }; +} 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..abf0b8ba --- /dev/null +++ b/src/winapp-npm/src/jsbindings/yaml-packages-hash.ts @@ -0,0 +1,242 @@ +// 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. + * + * 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(); + 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-commands.ts b/src/winapp-npm/src/winapp-commands.ts index 8315b3f1..fab8a5b4 100644 --- a/src/winapp-npm/src/winapp-commands.ts +++ b/src/winapp-npm/src/winapp-commands.ts @@ -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 new file mode 100644 index 00000000..18f6930b --- /dev/null +++ b/src/winapp-npm/test/cli-args.test.ts @@ -0,0 +1,93 @@ +// 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', () => { + // 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/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..14e07504 --- /dev/null +++ b/src/winapp-npm/test/package-manager-detector.test.ts @@ -0,0 +1,178 @@ +// 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, + resolvePackageManagerPath, +} 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'); + }); +}); + +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 }); + } +}); + +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 new file mode 100644 index 00000000..5ef0262d --- /dev/null +++ b/src/winapp-npm/test/path-safety.test.ts @@ -0,0 +1,72 @@ +// 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 { isNetworkPath, hasReparsePointOnPath } 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); +}); + +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 }); + } +}); 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"'); +}); 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"] +}