diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9304c7..53f19a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,15 @@ jobs: - name: Smoke test CLI commands with cached fixture run: npm run smoke:fixture + # Keep the version in sync with ATK_VERSION in m365-plugin.yml. + - name: Install Microsoft 365 Agents Toolkit CLI + env: + ATK_VERSION: 1.1.18 + run: npm install -g --no-audit --no-fund "@microsoft/m365agentstoolkit-cli@$ATK_VERSION" + + - name: Smoke test M365 plugin bundle and app package + run: npm run smoke:m365 -- --package + - name: Smoke test live catalog if: github.event_name != 'pull_request' run: npm run smoke:live diff --git a/.github/workflows/m365-plugin.yml b/.github/workflows/m365-plugin.yml new file mode 100644 index 0000000..2b9e3ce --- /dev/null +++ b/.github/workflows/m365-plugin.yml @@ -0,0 +1,130 @@ +name: M365 plugin + +# Builds the offline Microsoft 365 Copilot plugin app package (for Cowork) with live catalog data +# and publishes it as release m365-v, marked Latest, so +# releases/latest/download/microsoft-build-m365.zip serves it. The M365 plugin has its own +# version, separate from the plugin manifests, because catalog refreshes need new releases too. +on: + workflow_dispatch: + inputs: + version: + description: M365 plugin version (x.y.z), higher than the last m365-v* release. Cowork only updates to a higher version. + required: true + type: string + events: + description: Events to bundle, separated by spaces (for example "build-2026 ignite-2026"). Leave empty for all events. + required: false + type: string + default: '' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +env: + # Keep in sync with the atk install step in ci.yml. + ATK_VERSION: 1.1.18 + ZIP_NAME: microsoft-build-m365.zip + +jobs: + build: + name: Build M365 plugin app package + runs-on: ubuntu-latest + timeout-minutes: 20 + + defaults: + run: + working-directory: cli + + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22.x + cache: npm + cache-dependency-path: cli/package-lock.json + + - name: Check branch and version + env: + GH_TOKEN: ${{ github.token }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + VERSION: ${{ inputs.version }} + run: | + if [ "$GITHUB_REF" != "refs/heads/$DEFAULT_BRANCH" ]; then + echo "::error::Run this workflow from $DEFAULT_BRANCH; it publishes a public release." + exit 1 + fi + if ! printf '%s' "$VERSION" | grep -Eq '^[1-9][0-9]*\.[0-9]+\.[0-9]+$'; then + echo "::error::Version \"$VERSION\" must look like 1.2.3 and can't start with 0." + exit 1 + fi + tags="$(gh api --paginate "repos/$GITHUB_REPOSITORY/git/matching-refs/tags/m365-v" --jq '.[].ref | ltrimstr("refs/tags/m365-v")')" + last="$(printf '%s\n' "$tags" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n 1 || true)" + if [ -n "$last" ] && { [ "$VERSION" = "$last" ] || [ "$(printf '%s\n' "$last" "$VERSION" | sort -V | tail -n 1)" != "$VERSION" ]; }; then + echo "::error::Version $VERSION must be higher than the last M365 release, $last." + exit 1 + fi + + - name: Install dependencies + run: npm ci + + - name: Install Microsoft 365 Agents Toolkit CLI + run: npm install -g --no-audit --no-fund "@microsoft/m365agentstoolkit-cli@$ATK_VERSION" + + - name: Build app package + env: + VERSION: ${{ inputs.version }} + EVENTS: ${{ inputs.events }} + run: | + args=(--plugin-version "$VERSION" --package) + IFS=', ' read -r -a events <<< "$EVENTS" + for event in "${events[@]}"; do + if [ -n "$event" ]; then args+=(--event "$event"); fi + done + npm run build:m365 -- "${args[@]}" + cp ../out/m365-plugin-app/appPackage/build/appPackage.zip "../out/$ZIP_NAME" + + - name: Upload app package artifact + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + path: out/${{ env.ZIP_NAME }} + archive: false + if-no-files-found: error + + publish: + name: Publish release + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + + permissions: + contents: write + + steps: + - name: Download app package artifact + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: ${{ env.ZIP_NAME }} + path: release + skip-decompress: true + + - name: Create release + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ inputs.version }} + run: | + unzip -l "release/$ZIP_NAME" manifest.json + gh release create "m365-v$VERSION" "release/$ZIP_NAME" \ + --repo "$GITHUB_REPOSITORY" \ + --target "$GITHUB_SHA" \ + --title "M365 plugin $VERSION" \ + --generate-notes \ + --latest diff --git a/AGENTS.md b/AGENTS.md index 32131f5..40b0001 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,7 @@ The repo publishes plugin metadata for GitHub Copilot and Claude Code. Keep shar ## Sync rules - Event support is duplicated by design between the CLI and skill docs. When adding, removing, or renaming events, update `cli\src\config.ts`, the supported/default event guidance in `skills\microsoft-build\SKILL.md`, and affected tests/docs together. +- The offline Microsoft 365 Copilot plugin (Cowork) builds its SKILL.md from `skills\microsoft-build\SKILL.md`; there's no second copy to keep aligned. `cli\m365-skill\offline-overlay.md` holds the only Cowork-specific text: frontmatter keys to replace and a `## Session catalog access` section that uses only `node scripts/msevents.mjs` (no `npx`, network, or `refresh`). `SKILL_REWRITES` in `cli\scripts\build-m365-plugin.mjs` rewrites the remaining online steps, and the build fails when a rewrite stops matching or online wording (`npx`, catalog endpoints, `refresh`, "live catalog") is left, so `npm run smoke:m365` catches main-skill edits that need a matching update there. Changing the overlay or rewrites doesn't need a plugin version bump; the M365 plugin has its own version, entered when the M365 plugin workflow publishes a release. - Treat `skills\microsoft-build\SKILL.md` as the product contract for event-session behavior. For Build, Ignite, or event-session work: default "Build" to `build-2026`, get session metadata from the live catalog through `msevents` or the endpoint, get SDK/API facts from Microsoft Learn MCP, and never invent session IDs, speakers, schedules, or links. - Versioning gate: any meaningful `skills\microsoft-build\SKILL.md` change (agent behavior, commands/examples agents may follow, supported/default events, CLI/MCP/catalog usage, or user-facing workflow guidance) must bump both versioned plugin manifests to the same value; patch bumps are fine for guidance-only changes. - PR review gate: GitHub Copilot/code reviewers should flag meaningful `SKILL.md` changes without synced plugin version bumps, and any plugin version drift. Typo/format-only skill edits may skip the bump only when the PR explicitly says so. @@ -36,16 +37,23 @@ npm test npx vitest run test\search.test.ts npx vitest run test\search.test.ts -t "finds sessions by title keyword" npm run smoke:fixture +npm run smoke:m365 +npm run smoke:m365 -- --package +npm run build:m365 -- --plugin-version 1.0.7 --package ``` -`npm run smoke:live` hits the live catalog; CI only runs it outside pull requests. +`npm run smoke:live` hits the live catalog; CI only runs it outside pull requests. `npm run build:m365` also downloads live catalogs and writes the M365 plugin to `out\m365-plugin` (git-ignored); `--package` uses the `atk` CLI to build `out\m365-plugin-app\appPackage\build\appPackage.zip`. `smoke:m365 -- --package` also needs `atk`: it builds the zip from the fixture, unzips it and runs the offline checks on the unzipped skill, as CI does. Never commit generated snapshot data. + +`.github\workflows\m365-plugin.yml` publishes the M365 plugin. It runs manually on `main` with a version input, which must be `x.y.z` and higher than every `m365-v*` tag; the M365 plugin is versioned separately from the plugin manifests. An optional events input (space-separated event IDs, empty for all) is passed to the build as `--event` flags. It creates release `m365-v` with `microsoft-build-m365.zip` attached and marks it Latest, which the README links through `releases/latest/download/`, so don't mark other releases as Latest. Keep the build job read-only; only the job that creates the release gets `contents: write`. + +The M365 plugin bundles `cli\src\index.ts` (the whole CLI) with esbuild. Its banner sets `MSEVENTS_OFFLINE=1` and points `MSEVENTS_CACHE_DIR` at the bundled `data\` folder, and the version comes from a build-time `__MSEVENTS_VERSION__` constant because the bundle has no `package.json`. Keep offline mode free of network calls and cache writes; `npm run smoke:m365` checks this with read-only data. Note: The first time `npx @microsoft/events-cli` is run, it prompts for permission and can cause CLI tool calls to hang in AI agents. In all instructions or `SKILL.md`, prefer `npx -y @microsoft/events-cli` to include the `-y` flag. ## CLI behavior contracts - Catalog data has inconsistent shapes: fields may be strings, `{ displayValue }` objects, arrays, or empty values. Normalize through the helpers in `cli\src\data\normalize.ts`; records without `sessionCode` are intentionally skipped. -- Cache behavior is part of the CLI contract. `ensureCache` loads cached sessions, revalidates when due, fetches missing event caches, and only falls back to stale cache when the stale data is scoped to the request. Tests use `MSEVENTS_CACHE_DIR` to isolate cache state. +- Cache behavior is part of the CLI contract. `ensureCache` loads cached sessions, revalidates when due, fetches missing event caches, and only falls back to stale cache when the stale data is scoped to the request. With `MSEVENTS_OFFLINE=1` it only reads the cache and skips uncached events; `refresh` errors and `status` reports offline. Tests use `MSEVENTS_CACHE_DIR` to isolate cache state. - Search behavior is intentionally specialized: the `msevents sessions` command requires `--query`, `--tech`, or `--speaker`; code-like queries exact-match session codes and variants; technology search spans product/tags/topic/solution area/languages/title/description; speaker search post-filters on the queried last name. ## General principles diff --git a/README.md b/README.md index a46abb7..a349b0e 100644 --- a/README.md +++ b/README.md @@ -69,12 +69,13 @@ Session results are a starting point. For broad topics, ask the agent to refine | VS Code | Open Extensions (Ctrl+Shift+X), search `@agentPlugins microsoft-events`, and install | | Visual Studio 2026 | Copy `skills/microsoft-build/` to a [supported skill location](https://learn.microsoft.com/visualstudio/ide/copilot-agent-skills) | | Claude Code | `/plugin marketplace add microsoft/Build-CLI` then `/plugin install microsoft-events@microsoft-events-marketplace` | +| Microsoft 365 Copilot Cowork | Download [`microsoft-build-m365.zip`](https://github.com/microsoft/Build-CLI/releases/latest/download/microsoft-build-m365.zip) from the latest release and upload it in Cowork (**Customize** → **Plugins** → **Add plugin** → **Only you**). To build your own offline plugin, see [cli/README.md](cli/README.md#microsoft-365-copilot-plugin-offline) | ## Scope and Limitations - **Event-scoped:** Targets Build 2026, with Build 2025 and Ignite 2025 also available. The architecture supports any event that uses the same catalog endpoints. - **Dependency-driven:** Recommendations are only as good as the dependency files in your project root. If your project doesn't have a manifest file, tell the skill your stack directly. -- **Not offline:** Requires network access to query the catalog and Learn MCP server. +- **Not offline:** Requires network access to query the catalog and Learn MCP server. The M365 plugin is the exception: it searches catalog data bundled at build time, which goes stale until you install a newer release or rebuild it. ## Contributing diff --git a/cli/README.md b/cli/README.md index 435f848..51dca16 100644 --- a/cli/README.md +++ b/cli/README.md @@ -83,12 +83,14 @@ Use `--event ` to filter to a single event. Without it, commands search acro - **Network-friendly checks**: recent checks are skipped, stable catalogs are checked less often, and failed checks use backoff with jitter to avoid request spikes. - **Disambiguation**: if a session code exists in multiple events, the CLI shows options. - **Results**: 10 by default, `--limit` to override. +- **Offline mode**: with `MSEVENTS_OFFLINE=1`, commands read only what's already in the cache. They never check for updates or write to the cache folder, so it can be read-only. Events without a cache are skipped with a note, `refresh` exits with an error, and `status` says it's offline (`"offline": true` on each `--json` entry). ## Environment variables | Variable | Default | Purpose | |----------|---------|---------| | `MSEVENTS_CACHE_DIR` | per-OS cache path | Override the local cache directory. | +| `MSEVENTS_OFFLINE` | unset | Set to `1` or `true` to use only cached data, with no network access or cache writes. | | `MSEVENTS_FETCH_TIMEOUT_MS` | `30000` | Abort catalog requests after this many milliseconds. | | `MSEVENTS_MAX_RESPONSE_BYTES` | `52428800` (50 MiB) | Reject catalog responses larger than this. | @@ -103,3 +105,58 @@ npm run build npm test node dist/index.js --help ``` + +## Microsoft 365 Copilot plugin (offline) + +Microsoft 365 Copilot Cowork runs skill scripts in a sandbox with Node.js but no network access, so `npx -y @microsoft/events-cli` can't run there. `npm run build:m365` produces a Cowork-ready plugin that ships the whole `msevents` CLI as one bundled file, `scripts/msevents.mjs`, together with a frozen snapshot of its cache in `data/`. The bundle always runs in offline mode (`MSEVENTS_OFFLINE`) with its cache folder set to `../data/`, so search results match `msevents` for the same catalog data. + +Each [M365 plugin release](https://github.com/microsoft/Build-CLI/releases) (tagged `m365-v`) includes a prebuilt `microsoft-build-m365.zip` (see [Publishing the M365 plugin](#publishing-the-m365-plugin)). To build your own: + +```bash +cd cli +npm ci +npm run build:m365 -- --plugin-version 1.0.7 --package +``` + +| Option | Default | Purpose | +|--------|---------|---------| +| `--plugin-version ` | repo plugin version | Plugin version. Cowork rejects versions starting with `0` and requires a higher version for every reinstall. | +| `--package` | off | Also build the installable app package zip with the [Microsoft 365 Agents Toolkit CLI](https://www.npmjs.com/package/@microsoft/m365agentstoolkit-cli) (`npm install -g @microsoft/m365agentstoolkit-cli`). | +| `--event ` | all supported events | Event to include; repeat for more than one. | +| `--out ` | `/out/m365-plugin` | Plugin output folder (git-ignored). `--package` writes the app project to `-app`. Only previous outputs are overwritten. | +| `--sessions-dir ` | download live | Use an existing `msevents` cache folder (`-sessions.json` and `-meta.json`) instead of downloading. | + +Output layout: + +```text +out/ +├── m365-plugin/ # plugin folder +│ ├── .claude-plugin/plugin.json +│ ├── .mcp.json +│ └── skills/microsoft-build/ +│ ├── SKILL.md # skills/microsoft-build/SKILL.md, rewritten for offline use +│ ├── scripts/msevents.mjs # the msevents CLI, bundled and locked to offline mode +│ └── data/ # frozen cache: -sessions.json and -meta.json +└── m365-plugin-app/ # with --package: atk project + └── appPackage/build/appPackage.zip +``` + +The Cowork SKILL.md is built from the repo skill, `skills/microsoft-build/SKILL.md`, so both stay in sync. The repo skill tells the agent to use `npx`, the catalog endpoints and `refresh`, none of which work in Cowork's offline sandbox, so the build: + +- replaces the `## Session catalog access` section and the `compatibility`, `metadata` and `allowed-tools` frontmatter with [`cli/m365-skill/offline-overlay.md`](m365-skill/offline-overlay.md), filling in the snapshot date and bundled events; +- rewrites the remaining online steps (`npx -y @microsoft/events-cli` becomes `node scripts/msevents.mjs`, "Without CLI" fallbacks and catalog URLs are removed) with `SKILL_REWRITES` in `scripts/build-m365-plugin.mjs`; +- fails if a rewrite no longer matches the repo skill, if online wording is left, or if the result breaks Cowork's skill validation rules. + +When you edit the repo skill, run `npm run smoke:m365`; CI runs it too. The result is about 28,000 characters (roughly 7,000 tokens), above the ~5,000 tokens Cowork recommends for a skill body but within what it accepts. + +To install, upload the zip (`appPackage.zip` from a local build, or `microsoft-build-m365.zip` from a release) in Cowork (**Customize** → **Plugins** → **Add plugin** → **Only you**), or sideload it with `atk install --file-path --scope Personal`. Plugin skills appear only in new conversations. + +The snapshot doesn't update itself. Install a newer release, or rebuild with a higher `--plugin-version` and reinstall, to pick up catalog changes. `npm run smoke:m365` builds from a fixture offline, makes the data read-only, and checks that the bundle's output matches `msevents`. With `-- --package` (needs `atk`), it also builds the app package zip, unzips it, checks that its skill files match the plugin folder, and runs the same checks against the unzipped skill; CI runs it this way. + +### Publishing the M365 plugin + +The [M365 plugin workflow](../.github/workflows/m365-plugin.yml) builds and publishes the plugin. Run **M365 plugin** from the **Actions** tab on `main` and enter a version. It runs `npm run build:m365 -- --package` with live catalog data, then creates a release tagged `m365-v` with `microsoft-build-m365.zip` attached and marks it **Latest**, so [`releases/latest/download/microsoft-build-m365.zip`](https://github.com/microsoft/Build-CLI/releases/latest/download/microsoft-build-m365.zip) serves it. + +- **Version:** the M365 plugin is versioned separately from the plugin manifests, because a catalog refresh needs a new release even without code changes. Enter `x.y.z`, higher than the last `m365-v*` release; Cowork only updates an installed plugin to a higher version. +- **Events:** leave the events field empty to bundle every supported event, or enter event IDs separated by spaces, such as `build-2026 ignite-2026`, to bundle only those. Use it when an event's catalog can't be downloaded, because the build fails if any requested event fails. +- **Testing:** the release is public as soon as the workflow finishes. To try changes first, build locally with `npm run build:m365 -- --plugin-version --package` and install the zip. diff --git a/cli/m365-skill/offline-overlay.md b/cli/m365-skill/offline-overlay.md new file mode 100644 index 0000000..b84e682 --- /dev/null +++ b/cli/m365-skill/offline-overlay.md @@ -0,0 +1,96 @@ +--- +# Offline changes for the M365 plugin's SKILL.md. `npm run build:m365` builds that SKILL.md from +# skills/microsoft-build/SKILL.md: each key here replaces the same frontmatter key (an empty value +# removes it), and the body below replaces the "## Session catalog access" section. The build +# script rewrites the rest of the skill for offline use; see SKILL_REWRITES in +# cli/scripts/build-m365-plugin.mjs. +compatibility: >- + Built for sandboxes without internet access, such as Microsoft 365 Copilot + Cowork. Requires Node.js 22 or later. Session data comes from an offline + snapshot: scripts/msevents.mjs searches the catalogs bundled in data/. Docs + come from the Microsoft Learn MCP Server or the agent's built-in web tools. +metadata: + author: Microsoft Learn partnerships team + domain: microsoft-build + snapshot: "{{SNAPSHOT_DATE}}" + msevents: "{{CLI_VERSION}}" +allowed-tools: +--- + +## Session catalog access + +Session data comes from catalogs bundled with this skill, downloaded {{SNAPSHOT_DATE}}. This sandbox has no internet access, so it can't reach the event sites' session catalogs. + +- The first time in a conversation that you recommend sessions or give session times or rooms, add one short note such as "Based on session data from {{SNAPSHOT_DATE}}; check the event site for the latest schedule," linking the event site from **Key resources**. Don't repeat it in later answers. +- If the user asks whether the data is live or up to date, answer truthfully: it was downloaded {{SNAPSHOT_DATE}}, and sessions, rooms, or times may have changed since. Never claim it's live. + +### Bundled events + +| Event | Event ID | Sessions | Downloaded | +|-------|----------|----------|------------| +{{SNAPSHOT_EVENTS}} + +If an event isn't in this table, the script returns no sessions for it. Tell the user session data for that event isn't available here instead of guessing. + +### msevents CLI (bundled) + +Run the bundled script with Node from this skill's folder. If the working directory is different, use the full path to `scripts/msevents.mjs` inside this skill's folder. + +```sh +# Search by keyword +node scripts/msevents.mjs sessions --query "Microsoft Foundry" --event build-2026 --json + +# Search by technology (matches product, tags, topic, solution area, languages, title, description) +node scripts/msevents.mjs sessions --tech "Azure Cosmos DB" --event build-2026 --json + +# Search by speaker +node scripts/msevents.mjs sessions --speaker "Scott Hanselman" --event build-2026 --json + +# Combine filters +node scripts/msevents.mjs sessions --tech "Microsoft Foundry" --type lab --event build-2026 --json + +# Look up a specific session by code +node scripts/msevents.mjs session BRK155 --event build-2026 --json + +# Show session counts and when each catalog was downloaded +node scripts/msevents.mjs status +``` + +Rules for the sandbox: + +- Use only `node scripts/msevents.mjs` for session data. It's the `msevents` CLI bundled into one file, running offline against the catalogs in `data/`. Don't run `npx`, `npm`, `curl`, or HTTP requests for the catalog; the sandbox has no internet access and they will fail or hang. +- Don't read or parse `data/*.json` directly. The script applies the CLI's search ranking, session-code matching, and speaker filtering. +- The bundled catalogs can't be updated from inside the sandbox. +- Use `--json` for structured output you can parse. + +#### CLI reference + +| Command | Options | Description | +|---------|---------|-------------| +| `sessions` | `--query ` | Keyword search across all fields (boosts title); session codes match exactly | +| | `--tech ` | Search by technology | +| | `--speaker ` | Search by speaker name | +| | `--type ` | Filter by session type (breakout, lab, demo, keynote, etc.) | +| | `--event ` | Filter by event ID | +| | `--limit ` | Max results, default 10, max 200 | +| | `--json` | Output as JSON | +| `session ` | `--event ` | Scope to one event (for disambiguation) | +| | `--json` | Output as JSON | +| `status` | `--json` | Session counts per event and when each catalog was downloaded | + +`sessions` needs at least one of `--query`, `--tech`, or `--speaker`. + +Session fields: `sessionCode`, `title`, `description`, `speakers`, `timeSlot`, `startDateTime`, `endDateTime`, `location`, `level`, `type`, `topic`, `solutionArea`, `product`, `languages`, `tags`, `deliveryTypes`, `viewingOptions`, `hasLiveStream`, `hasOnDemand`, `relatedSessionCodes`, `slideDeck`, `onDemand`, `event`. + +### Learn MCP Server + +Use the Microsoft Learn MCP tools when they're available: + +| Tool | When to use | +|------|-------------| +| `microsoft_docs_search` | Find current docs for an SDK, service, or feature | +| `microsoft_docs_fetch` | Read a full documentation page | +| `microsoft_code_sample_search` | Find official code samples | + +If Learn MCP tools aren't available, use your built-in web search or page fetch on `learn.microsoft.com` and the News & Announcements links. These tools run outside the sandbox. Never use them to download the session catalog; session data comes only from the bundled catalogs in `data/`, read through `scripts/msevents.mjs`. + diff --git a/cli/package-lock.json b/cli/package-lock.json index c941651..29fe58d 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -18,6 +18,7 @@ }, "devDependencies": { "@types/node": "^22.0.0", + "esbuild": "^0.28.2", "typescript": "^5.9.0", "vitest": "^4.0.0" }, @@ -59,6 +60,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -611,6 +1054,48 @@ "dev": true, "license": "MIT" }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", diff --git a/cli/package.json b/cli/package.json index 0782fa7..b9864d4 100644 --- a/cli/package.json +++ b/cli/package.json @@ -12,8 +12,10 @@ ], "scripts": { "build": "tsc -p tsconfig.json", + "build:m365": "npm run build && node scripts/build-m365-plugin.mjs", "smoke:fixture": "node scripts/smoke-fixture.mjs", "smoke:live": "node scripts/smoke-live.mjs", + "smoke:m365": "node scripts/smoke-m365-plugin.mjs", "test": "vitest run" }, "keywords": [ @@ -41,6 +43,7 @@ }, "devDependencies": { "@types/node": "^22.0.0", + "esbuild": "^0.28.2", "typescript": "^5.9.0", "vitest": "^4.0.0" } diff --git a/cli/scripts/build-m365-plugin.mjs b/cli/scripts/build-m365-plugin.mjs new file mode 100644 index 0000000..7e90f7e --- /dev/null +++ b/cli/scripts/build-m365-plugin.mjs @@ -0,0 +1,562 @@ +// Builds the Microsoft 365 Copilot plugin (a Claude-format plugin folder for Cowork) with an offline +// session snapshot: skills/microsoft-build/{SKILL.md, scripts/msevents.mjs, data/-*.json}. +// scripts/msevents.mjs is the whole msevents CLI bundled into one file. Its banner turns on +// offline mode (MSEVENTS_OFFLINE) and points the cache at ../data, so data/ is a frozen cache. +// SKILL.md is the repo skill (skills/microsoft-build/SKILL.md) rewritten for offline use with +// cli/m365-skill/offline-overlay.md. Run through `npm run build:m365` so dist/ is up to date. +import { execFile } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { delimiter, dirname, isAbsolute, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs, promisify } from 'node:util'; +import { build } from 'esbuild'; +import { KNOWN_EVENTS } from '../dist/config.js'; + +const execFileAsync = promisify(execFile); +const cliRoot = fileURLToPath(new URL('..', import.meta.url)); +const repoRoot = resolve(cliRoot, '..'); +const SKILL_NAME = 'microsoft-build'; +const SCRIPT_FILE = 'msevents.mjs'; +const MB = 1024 * 1024; +// Cowork limits for files that ship next to SKILL.md. +const COMPANION_LIMITS = { files: 20, fileBytes: 5 * MB, totalBytes: 10 * MB }; +const SKILL_DESCRIPTION_MAX_CHARS = 1024; +const SKILL_SOURCE = join(repoRoot, 'skills', SKILL_NAME, 'SKILL.md'); +const SKILL_OVERLAY = join(cliRoot, 'm365-skill', 'offline-overlay.md'); +// The repo skill section that the overlay's body replaces. +const SKILL_SECTION = '## Session catalog access'; + +// Rewrites that turn the repo skill's online steps into offline ones. Each one must match, so a +// wording change in skills/microsoft-build/SKILL.md fails the build until this list is updated. +const SKILL_REWRITES = [ + { + find: /Uses the msevents CLI for fast local search and\s+Learn MCP Server for docs\./, + replace: 'Searches session catalogs bundled with this skill and uses Microsoft Learn for docs.', + }, + { find: 'event ID and endpoint from', replace: 'event ID from' }, + { find: ' and its endpoint for direct fetch', replace: '' }, + { find: 'Two live data sources, no static files:', replace: 'Two data sources:' }, + { + find: 'accessed via the msevents CLI for local search, caching, and multi-event support', + replace: 'bundled with this skill and searched offline with `scripts/msevents.mjs`', + }, + { find: 'the live catalog (via CLI or endpoint)', replace: 'the bundled catalogs (via `scripts/msevents.mjs`)' }, + { find: '(CLI fails and endpoint is down)', replace: '(`scripts/msevents.mjs` fails)' }, + { find: ' (or the mslearn CLI fallback)', replace: '' }, + { find: 'npx -y @microsoft/events-cli', replace: 'node scripts/msevents.mjs' }, + { find: /^[ \t]*- \*\*Without CLI\*\*:.*\n/gm, replace: '' }, + { find: '**With CLI**: ', replace: '' }, + { dropColumn: 'Catalog Endpoint' }, + { find: /^\|.*-session-info.*\n/gm, replace: '' }, + { find: /^\| CLI source \|.*\n/m, replace: '' }, +]; + +// Online-only instructions that must not reach the offline skill. The overlay's section is exempt. +const OFFLINE_FORBIDDEN = [ + [/\bnpx\b|@microsoft\/[\w-]+-cli\b|\bmslearn\b/, 'npx or an npm CLI'], + [/\brefresh\b/i, 'refreshing the catalog'], + [/-session-info\b|\bendpoints?\b|\bdirect HTTP fetch\b|\bfetch the catalog\b/i, 'the online session catalog'], + [/\blive catalog\b|\*\*With(out)? CLI\*\*/i, 'the live catalog'], + [/\.\.\/\.\.\/cli\b/, 'a repo path'], +]; +const PRIVACY_URL = 'https://privacy.microsoft.com/privacystatement'; +const TERMS_URL = 'https://www.microsoft.com/servicesagreement'; +const ATK_PACKAGE = '@microsoft/m365agentstoolkit-cli'; + +const USAGE = `Usage: npm run build:m365 -- [options] + +Builds the Microsoft 365 Copilot plugin for Cowork with an offline session snapshot. + +Options: + --out Output folder (default: /out/m365-plugin) + --event Event to include; repeat for more (default: all known events) + --sessions-dir Use existing msevents cache files (-sessions.json and + -meta.json) instead of downloading + --plugin-version Override the plugin version (must not start with 0; bump for every reinstall) + --package Also build the installable app package zip with the Microsoft 365 + Agents Toolkit CLI (atk) into -app + -h, --help Show this help`; + +class BuildError extends Error {} + +function fail(message) { + throw new BuildError(message); +} + +const lf = (text) => text.replace(/\r\n?/g, '\n'); + +async function writeText(path, text) { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, lf(text)); +} + +async function readJson(path) { + return JSON.parse(await readFile(path, 'utf8')); +} + +function isInside(parent, child) { + const rel = relative(parent, child); + return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); +} + +function resolveEvents(ids) { + const wanted = ids?.length ? [...new Set(ids)] : KNOWN_EVENTS.map((e) => e.id); + return wanted.map((id) => KNOWN_EVENTS.find((e) => e.id === id) + ?? fail(`Unknown event "${id}". Known events: ${KNOWN_EVENTS.map((e) => e.id).join(', ')}`)); +} + +async function readPluginManifest(versionOverride) { + const manifest = await readJson(join(repoRoot, '.claude-plugin', 'plugin.json')); + if (versionOverride) manifest.version = versionOverride; + if (typeof manifest.version !== 'string' || !/^[1-9]\d*\.\d+(\.\d+)?$/.test(manifest.version)) { + fail(`Plugin version "${manifest.version}" isn't valid for Cowork. Use a version that doesn't start with 0, such as 1.0.7, and increase it for every reinstall.`); + } + if (!manifest.homepage) fail('.claude-plugin/plugin.json needs a "homepage"; Cowork requires it.'); + if (manifest.skills !== './skills/') fail(`Expected "skills": "./skills/" in .claude-plugin/plugin.json, got ${JSON.stringify(manifest.skills)}`); + return manifest; +} + +// Empties a generated output folder, but only when it looks like one of our previous outputs. +async function clearOutputDir(dir, markers, description) { + if (isInside(dir, repoRoot) || isInside(dir, cliRoot)) { + fail(`Refusing to use ${dir} as output: it contains the repository.`); + } + if (existsSync(dir) && (await readdir(dir)).length > 0) { + if (!markers.every((marker) => existsSync(join(dir, ...marker)))) { + fail(`Refusing to overwrite ${dir}: it isn't empty and doesn't look like a previous ${description}.`); + } + await rm(dir, { recursive: true, force: true }); + } +} + +async function prepareOutDir(outDir) { + await clearOutputDir( + outDir, + [['.claude-plugin', 'plugin.json'], ['skills', SKILL_NAME, 'scripts', SCRIPT_FILE]], + 'M365 plugin build', + ); + await mkdir(outDir, { recursive: true }); +} + +const ATK_MISSING = `--package needs the Microsoft 365 Agents Toolkit CLI. Install it with: npm install -g ${ATK_PACKAGE}`; + +// atk is started without a shell so paths reach it unchanged. On Windows, npm installs atk as a +// .cmd shim that Node can only start through cmd.exe, so run the package's JS entry with node instead. +async function resolveAtk() { + if (process.platform !== 'win32') return { file: 'atk', args: [] }; + for (const dir of (process.env.PATH ?? '').split(delimiter).filter(Boolean)) { + if (existsSync(join(dir, 'atk.exe'))) return { file: join(dir, 'atk.exe'), args: [] }; + const pkgDir = join(dir, 'node_modules', ...ATK_PACKAGE.split('/')); + if (!existsSync(join(pkgDir, 'package.json'))) continue; + const { bin } = await readJson(join(pkgDir, 'package.json')); + const entry = typeof bin === 'string' ? bin : bin?.atk; + if (entry) return { file: process.execPath, args: [join(pkgDir, entry)] }; + } + fail(ATK_MISSING); +} + +async function runAtk(args, cwd) { + const atk = await resolveAtk(); + try { + return await execFileAsync(atk.file, [...atk.args, ...args], { cwd, timeout: 300_000, maxBuffer: 16 * MB }); + } catch (err) { + if (err.code === 'ENOENT') fail(ATK_MISSING); + const output = `${err.stdout ?? ''}${err.stderr ?? ''}`.trim(); + fail(`atk ${args[0]} ${args[1] ?? ''} failed:\n${output || err.message}`); + } +} + +// Turns the plugin folder into an installable app package with the Microsoft 365 Agents Toolkit CLI. +async function packageApp(outDir, version) { + const appDir = `${outDir}-app`; + await clearOutputDir(appDir, [['m365agents.yml'], ['appPackage', 'manifest.json']], 'atk app project'); + console.log('Packaging with the Microsoft 365 Agents Toolkit CLI...'); + await runAtk([ + 'import', 'openplugin', + '--path', outDir, + '--output', appDir, + '--privacy-url', PRIVACY_URL, + '--terms-url', TERMS_URL, + ], dirname(outDir)); + + const manifestFile = join(appDir, 'appPackage', 'manifest.json'); + if (!existsSync(manifestFile)) fail(`atk import openplugin didn't create ${manifestFile}`); + const manifest = await readJson(manifestFile); + if (manifest.version !== version) fail(`atk wrote app version ${manifest.version}; expected ${version}`); + if (!manifest.agentSkills?.some((s) => s.folder === `./skills/${SKILL_NAME}`)) { + fail(`atk didn't register skills/${SKILL_NAME} in ${manifestFile}`); + } + + const zip = join(appDir, 'appPackage', 'build', 'appPackage.zip'); + await runAtk([ + 'package', + '--manifest-file', './appPackage/manifest.json', + '--output-package-file', './appPackage/build/appPackage.zip', + '--output-folder', './appPackage/build', + ], appDir); + if (!existsSync(zip)) fail(`atk package didn't create ${zip}`); + return { zip, size: (await stat(zip)).size }; +} + +// Reads one event from an msevents cache folder and returns the files for the frozen cache. +async function readCachedEvent(dir, event) { + const file = join(dir, `${event.id}-sessions.json`); + if (!existsSync(file)) fail(`Missing ${file}`); + + const sessions = await readJson(file); + if (!Array.isArray(sessions) || sessions.length === 0) fail(`${file} has no sessions`); + const seen = new Set(); + for (const session of sessions) { + if (typeof session?.sessionCode !== 'string' || !session.sessionCode || session.event !== event.id) { + fail(`${file} contains a record that isn't a normalized ${event.id} session`); + } + if (seen.has(session.sessionCode)) { + fail(`${file} repeats session code ${session.sessionCode}; the search index needs unique codes per event`); + } + seen.add(session.sessionCode); + } + + const metaFile = join(dir, `${event.id}-meta.json`); + const cacheMeta = existsSync(metaFile) ? await readJson(metaFile) : {}; + const fetchedAt = typeof cacheMeta.fetchedAt === 'string' + ? cacheMeta.fetchedAt + : (await stat(file)).mtime.toISOString(); + + // Keep only what offline mode and `status` use; drop revalidation and failure state. + const meta = { + eventId: event.id, + fetchedAt, + sessionCount: sessions.length, + ...(typeof cacheMeta.lastModified === 'string' ? { lastModified: cacheMeta.lastModified } : {}), + }; + return { event, sessions, meta }; +} + +async function downloadSessions(events) { + const cacheDir = await mkdtemp(join(tmpdir(), 'msevents-m365-')); + try { + const results = []; + for (const event of events) { + console.log(`Downloading ${event.id}...`); + const { stdout, stderr } = await execFileAsync( + process.execPath, + ['dist/index.js', 'refresh', '--event', event.id, '--force'], + { + cwd: cliRoot, + env: { ...process.env, MSEVENTS_CACHE_DIR: cacheDir, MSEVENTS_OFFLINE: '' }, + timeout: 120_000, + }, + ); + if (!existsSync(join(cacheDir, `${event.id}-sessions.json`))) { + fail(`Couldn't download ${event.id}:\n${stdout}${stderr}`.trimEnd()); + } + results.push(await readCachedEvent(cacheDir, event)); + } + return results; + } finally { + await rm(cacheDir, { recursive: true, force: true }); + } +} + +function splitFrontmatter(markdown, file) { + const match = /^---\n([\s\S]*?)\n---\n/.exec(markdown); + if (!match) fail(`${file} is missing YAML frontmatter`); + return { frontmatter: match[1], body: markdown.slice(match[0].length) }; +} + +function readSkillFrontmatter(markdown) { + const lines = splitFrontmatter(markdown, 'SKILL.md').frontmatter.split('\n'); + const read = (key) => { + const index = lines.findIndex((line) => line.startsWith(`${key}:`)); + if (index === -1) fail(`SKILL.md frontmatter is missing "${key}"`); + const inline = lines[index].slice(key.length + 1).trim(); + if (!/^[>|][+-]?$/.test(inline)) return inline.replace(/^(["'])(.*)\1$/, '$2'); + const block = []; + for (const line of lines.slice(index + 1)) { + if (line.trim() !== '' && !/^\s/.test(line)) break; + block.push(line.trim()); + } + return block.join(' ').trim(); + }; + + return { name: read('name'), description: read('description') }; +} + +// Splits YAML frontmatter into top-level entries (a `key:` line plus its indented lines), dropping comments. +function frontmatterEntries(yaml) { + const entries = []; + for (const line of yaml.split('\n')) { + if (line.startsWith('#')) continue; + const key = /^([A-Za-z][\w-]*):/.exec(line)?.[1]; + if (key) entries.push({ key, lines: [line] }); + else if (entries.length) entries.at(-1).lines.push(line); + else if (line.trim()) fail(`Unexpected frontmatter line: ${line}`); + } + return entries; +} + +// Replaces the repo skill's frontmatter keys with the overlay's; an empty overlay value removes the key. +function mergeFrontmatter(base, overrides) { + const entries = frontmatterEntries(base); + for (const override of frontmatterEntries(overrides)) { + const index = entries.findIndex((entry) => entry.key === override.key); + const remove = override.lines.join('\n').slice(override.key.length + 1).trim() === ''; + if (remove && index === -1) fail(`The overlay removes frontmatter key "${override.key}", which the repo skill doesn't have`); + if (remove) entries.splice(index, 1); + else if (index === -1) entries.push(override); + else entries[index] = override; + } + return entries.flatMap((entry) => entry.lines).join('\n'); +} + +function replaceSection(markdown, heading, replacement) { + const start = markdown.indexOf(`\n${heading}\n`); + if (start === -1 || markdown.indexOf(`\n${heading}\n`, start + 1) !== -1) { + fail(`Expected one "${heading}" section in skills/${SKILL_NAME}/SKILL.md for the offline overlay to replace`); + } + const end = markdown.indexOf('\n## ', start + 1); + if (end === -1) fail(`Expected another "## " section after "${heading}" in skills/${SKILL_NAME}/SKILL.md`); + return `${markdown.slice(0, start + 1)}${replacement}${markdown.slice(end + 1)}`; +} + +// Removes a column, by header text, from every Markdown table that has it. +function dropTableColumn(markdown, header) { + const lines = markdown.split('\n'); + let count = 0; + for (let i = 0; i < lines.length; i++) { + if (!lines[i].startsWith('|') || lines[i - 1]?.startsWith('|')) continue; + const column = lines[i].split('|').findIndex((cell) => cell.trim() === header); + if (column === -1) continue; + count++; + for (let row = i; row < lines.length && lines[row].startsWith('|'); row++) { + const cells = lines[row].split('|'); + cells.splice(column, 1); + lines[row] = cells.join('|'); + } + } + return { text: lines.join('\n'), count }; +} + +function applyRewrite(text, rewrite) { + let result; + if (rewrite.dropColumn) { + result = dropTableColumn(text, rewrite.dropColumn); + } else if (typeof rewrite.find === 'string') { + result = { text: text.replaceAll(rewrite.find, rewrite.replace), count: text.split(rewrite.find).length - 1 }; + } else { + const count = rewrite.find.global ? (text.match(rewrite.find)?.length ?? 0) : Number(rewrite.find.test(text)); + result = { text: text.replace(rewrite.find, rewrite.replace), count }; + } + if (result.count === 0) { + const what = rewrite.dropColumn ? `table column "${rewrite.dropColumn}"` : String(rewrite.find); + fail(`skills/${SKILL_NAME}/SKILL.md no longer contains ${what}. Update SKILL_REWRITES in cli/scripts/build-m365-plugin.mjs to match it.`); + } + return result.text; +} + +function fillPlaceholders(template, values) { + return template.replace(/\{\{\s*([A-Z_]+)\s*\}\}/g, (match, key) => { + const value = values[key]; + if (value === undefined) fail(`Unknown placeholder ${match} in cli/m365-skill/offline-overlay.md`); + return value; + }); +} + +// Builds the Cowork SKILL.md from the repo skill: the overlay replaces frontmatter keys and the +// catalog-access section, SKILL_REWRITES fix the rest, and OFFLINE_FORBIDDEN catches anything missed. +function buildSkill(source, overlay, values) { + const repoSkill = splitFrontmatter(lf(source), `skills/${SKILL_NAME}/SKILL.md`); + const offline = splitFrontmatter(fillPlaceholders(lf(overlay), values), 'cli/m365-skill/offline-overlay.md'); + const marker = '\u0000OFFLINE_SECTION\u0000'; + + let text = `---\n${mergeFrontmatter(repoSkill.frontmatter, offline.frontmatter)}\n---\n${replaceSection(repoSkill.body, SKILL_SECTION, marker)}`; + for (const rewrite of SKILL_REWRITES) text = applyRewrite(text, rewrite); + const lines = text.split('\n'); + for (const [pattern, what] of OFFLINE_FORBIDDEN) { + const line = lines.find((l) => pattern.test(l)); + if (line !== undefined) { + fail(`The M365 SKILL.md would still mention ${what}, which doesn't work offline:\n ${line.trim()}\nUpdate SKILL_REWRITES in cli/scripts/build-m365-plugin.mjs or cli/m365-skill/offline-overlay.md for this change in skills/${SKILL_NAME}/SKILL.md.`); + } + } + const skill = text.replace(marker, `${offline.body.trim()}\n\n`); + + const { name, description } = readSkillFrontmatter(skill); + if (name !== SKILL_NAME) fail(`Skill name is "${name}"; expected "${SKILL_NAME}"`); + if (!description || description.length > SKILL_DESCRIPTION_MAX_CHARS) { + fail(`SKILL.md description must be 1-${SKILL_DESCRIPTION_MAX_CHARS} characters (got ${description.length})`); + } + return skill; +} + +async function licenseNotice(pkg) { + const pkgDir = join(cliRoot, 'node_modules', pkg); + const { version, license } = await readJson(join(pkgDir, 'package.json')); + const licenseFile = (await readdir(pkgDir)).find((name) => /^licen[cs]e(\.(md|txt))?$/i.test(name)); + if (!licenseFile) fail(`Bundled package ${pkg} has no license file to include`); + const text = lf(await readFile(join(pkgDir, licenseFile), 'utf8')).trim().replaceAll('*/', '* /'); + return `${pkg}@${version} (${license})\n\n${text}`; +} + +async function bundleCli(cliVersion) { + const result = await build({ + absWorkingDir: cliRoot, + entryPoints: ['dist/index.js'], + bundle: true, + platform: 'node', + format: 'esm', + target: 'node22', + write: false, + metafile: true, + legalComments: 'none', + logLevel: 'silent', + // The bundle can't read ../package.json at runtime, so bake the version in. + define: { __MSEVENTS_VERSION__: JSON.stringify(cliVersion) }, + }); + + const inputs = Object.keys(result.metafile.inputs).map((p) => p.replaceAll('\\', '/')); + const packages = [...new Set(inputs + .map((p) => /(?:^|\/)node_modules\/((?:@[^/]+\/)?[^/]+)\//.exec(p)?.[1]) + .filter(Boolean))].sort(); + const notices = await Promise.all(packages.map(licenseNotice)); + + // The banner runs before the bundled CLI code, so these settings win over the environment. + const header = [ + '#!/usr/bin/env node', + `// msevents ${cliVersion} (@microsoft/events-cli), bundled by \`npm run build:m365\` for offline use.`, + '// Source: https://github.com/microsoft/Build-CLI/tree/main/cli. Do not edit; rebuild instead.', + '// Runs in offline mode (MSEVENTS_OFFLINE) and reads the frozen session cache in ../data/.', + "import { createRequire as __mseventsCreateRequire } from 'node:module';", + "import { fileURLToPath as __mseventsFileURLToPath } from 'node:url';", + 'const require = __mseventsCreateRequire(import.meta.url);', + "process.env.MSEVENTS_OFFLINE = '1';", + "process.env.MSEVENTS_CACHE_DIR = __mseventsFileURLToPath(new URL('../data/', import.meta.url));", + `/*\nBundled third-party packages:\n\n${notices.join('\n\n---\n\n')}\n*/`, + ].join('\n'); + const code = result.outputFiles[0].text.replace(/^#!.*\n/, ''); + return { code: `${header}\n${code}`, packages }; +} + +async function listFiles(dir, base = dir) { + const files = []; + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) files.push(...await listFiles(path, base)); + else files.push({ rel: relative(base, path).replaceAll('\\', '/'), size: (await stat(path)).size }); + } + return files; +} + +async function checkSkillFolder(skillDir) { + const companions = (await listFiles(skillDir)).filter((f) => f.rel !== 'SKILL.md'); + const total = companions.reduce((sum, f) => sum + f.size, 0); + if (companions.length > COMPANION_LIMITS.files) fail(`Skill has ${companions.length} companion files; Cowork allows ${COMPANION_LIMITS.files}`); + for (const f of companions) { + if (f.size > COMPANION_LIMITS.fileBytes) fail(`${f.rel} is ${(f.size / MB).toFixed(1)} MB; Cowork allows 5 MB per file`); + if (f.rel.split('/').some((part) => part.startsWith('.'))) fail(`${f.rel} is hidden; Cowork rejects hidden files`); + } + if (total > COMPANION_LIMITS.totalBytes) fail(`Skill companion files total ${(total / MB).toFixed(1)} MB; Cowork allows 10 MB`); + return { count: companions.length, total }; +} + +// Runs the bundled CLI from another folder, with the environment pointing elsewhere, to check +// that it always reads the bundled data offline. +async function verifyScript(skillDir, collected, cliVersion) { + const script = join(skillDir, 'scripts', SCRIPT_FILE); + const env = { ...process.env, MSEVENTS_OFFLINE: '0', MSEVENTS_CACHE_DIR: join(tmpdir(), 'msevents-m365-unused') }; + const run = (...args) => execFileAsync(process.execPath, [script, ...args], { cwd: tmpdir(), env, timeout: 60_000 }); + + const { stdout: version } = await run('--version'); + if (version.trim() !== cliVersion) fail(`Bundled CLI reported version "${version.trim()}"; expected ${cliVersion}`); + + const { stdout } = await run('status', '--json'); + const statuses = JSON.parse(stdout); + const counts = new Map(collected.map(({ meta }) => [meta.eventId, meta.sessionCount])); + const ok = Array.isArray(statuses) && statuses.length === KNOWN_EVENTS.length && statuses.every((s) => ( + s.offline === true && (s.meta?.sessionCount ?? null) === (counts.get(s.eventId) ?? null) + )); + if (!ok) fail(`Bundled CLI reported an unexpected status:\n${stdout}`); +} + +async function main() { + const { values } = parseArgs({ + options: { + out: { type: 'string' }, + event: { type: 'string', multiple: true }, + 'sessions-dir': { type: 'string' }, + 'plugin-version': { type: 'string' }, + package: { type: 'boolean' }, + help: { type: 'boolean', short: 'h' }, + }, + }); + if (values.help) { + console.log(USAGE); + return; + } + + const events = resolveEvents(values.event); + const manifest = await readPluginManifest(values['plugin-version']); + const { version: cliVersion } = await readJson(join(cliRoot, 'package.json')); + const outDir = resolve(values.out ?? join(repoRoot, 'out', 'm365-plugin')); + const skillDir = join(outDir, 'skills', SKILL_NAME); + + const collected = values['sessions-dir'] + ? await Promise.all(events.map((e) => readCachedEvent(resolve(values['sessions-dir']), e))) + : await downloadSessions(events); + const snapshotDate = collected.map(({ meta }) => meta.fetchedAt).sort()[0].slice(0, 10); + + const skill = buildSkill(await readFile(SKILL_SOURCE, 'utf8'), await readFile(SKILL_OVERLAY, 'utf8'), { + SNAPSHOT_DATE: snapshotDate, + CLI_VERSION: cliVersion, + SNAPSHOT_EVENTS: collected + .map(({ event, meta }) => `| ${event.name} | \`${event.id}\` | ${meta.sessionCount} | ${meta.fetchedAt.slice(0, 10)} |`) + .join('\n'), + }); + const bundle = await bundleCli(cliVersion); + + await prepareOutDir(outDir); + await writeText(join(outDir, '.claude-plugin', 'plugin.json'), `${JSON.stringify(manifest, null, 2)}\n`); + if (manifest.mcpServers) { + await writeText(join(outDir, manifest.mcpServers), await readFile(join(repoRoot, manifest.mcpServers), 'utf8')); + } + await writeText(join(skillDir, 'SKILL.md'), skill); + await writeText(join(skillDir, 'scripts', SCRIPT_FILE), bundle.code); + for (const { event, sessions, meta } of collected) { + await writeText(join(skillDir, 'data', `${event.id}-sessions.json`), JSON.stringify(sessions)); + await writeText(join(skillDir, 'data', `${event.id}-meta.json`), `${JSON.stringify(meta, null, 2)}\n`); + } + + await verifyScript(skillDir, collected, cliVersion); + const { count, total } = await checkSkillFolder(skillDir); + const app = values.package ? await packageApp(outDir, manifest.version) : undefined; + + console.log(` +M365 plugin written to ${outDir} + Plugin: ${manifest.name} ${manifest.version} + Snapshot: ${snapshotDate} (${collected.map(({ meta }) => `${meta.eventId}: ${meta.sessionCount}`).join(', ')}) + SKILL.md: ${skill.length.toLocaleString('en-US')} characters, built from skills/${SKILL_NAME}/SKILL.md + Skill files: ${count} plus SKILL.md, ${(total / MB).toFixed(2)} MB; msevents ${cliVersion} bundled with ${bundle.packages.join(', ')} +`); + + if (app) { + console.log(`App package: ${app.zip} (${(app.size / MB).toFixed(2)} MB) + +Next steps: + Upload the zip in Cowork (Customize > Plugins > Add plugin > Only you), or run: + atk install --file-path "${app.zip}" --scope Personal + Then start a new Cowork conversation. Reinstalling an updated snapshot needs a higher --plugin-version.`); + } else { + console.log(`Next steps: + Rerun with --package to build the installable zip (needs: npm install -g ${ATK_PACKAGE}). + Reinstalling an updated snapshot needs a higher --plugin-version.`); + } +} + +try { + await main(); +} catch (err) { + console.error(`build:m365 failed: ${err instanceof Error ? err.message : String(err)}`); + if (!(err instanceof BuildError) && err instanceof Error && err.stack) console.error(err.stack); + process.exitCode = 1; +} diff --git a/cli/scripts/smoke-m365-plugin.mjs b/cli/scripts/smoke-m365-plugin.mjs new file mode 100644 index 0000000..ed13a18 --- /dev/null +++ b/cli/scripts/smoke-m365-plugin.mjs @@ -0,0 +1,219 @@ +// Builds the M365 plugin from the fixture catalog, then checks that the bundled CLI works +// offline from read-only data and matches the regular CLI's output for the same cached sessions. +// With --package, it also builds the app package zip with atk, unzips it, and runs the checks +// against the unzipped skill. +import { execFile } from 'node:child_process'; +import { chmod, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, relative } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { parseArgs, promisify } from 'node:util'; +import { normalizeCatalog } from '../dist/data/normalize.js'; + +const execFileAsync = promisify(execFile); +const cliRoot = fileURLToPath(new URL('..', import.meta.url)); +const commandTimeoutMs = 60_000; +const eventId = 'build-2025'; +const isWindows = process.platform === 'win32'; +const { values: options } = parseArgs({ options: { package: { type: 'boolean' } } }); + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +async function run(args, options) { + try { + const { stdout, stderr } = await execFileAsync(process.execPath, args, { timeout: commandTimeoutMs, ...options }); + return { code: 0, stdout, stderr }; + } catch (err) { + if (typeof err.code !== 'number') throw err; + return { code: err.code, stdout: err.stdout, stderr: err.stderr }; + } +} + +async function setReadOnly(dir, readOnly) { + for (const name of await readdir(dir)) await chmod(join(dir, name), readOnly ? 0o444 : 0o644); + if (!isWindows) await chmod(dir, readOnly ? 0o555 : 0o755); +} + +async function listFiles(dir) { + const entries = await readdir(dir, { recursive: true, withFileTypes: true }); + return entries + .filter((e) => e.isFile()) + .map((e) => relative(dir, join(e.parentPath, e.name)).replaceAll('\\', '/')) + .sort(); +} + +// Windows' built-in tar (bsdtar) reads zip files; a Git Bash tar earlier on PATH may not. +async function unzip(zip, dir) { + await mkdir(dir, { recursive: true }); + if (isWindows) { + await execFileAsync(join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'tar.exe'), ['-xf', zip, '-C', dir]); + } else { + await execFileAsync('unzip', ['-q', zip, '-d', dir]); + } +} + +const root = await mkdtemp(join(tmpdir(), 'msevents-m365-smoke-')); +const cacheDir = join(root, 'cache'); +const emptyCacheDir = join(root, 'empty-cache'); +const pluginDir = join(root, 'plugin'); +const builtSkillDir = join(pluginDir, 'skills', 'microsoft-build'); +const unzippedDir = join(root, 'unzipped'); +const skillDir = options.package ? join(unzippedDir, 'skills', 'microsoft-build') : builtSkillDir; +const dataDir = join(skillDir, 'data'); +const workDir = join(root, 'work'); +const noNetwork = join(root, 'no-network.mjs'); +let dataLocked = false; + +try { + const raw = JSON.parse(await readFile('test/fixtures/build-2025-sample.json', 'utf8')); + const sessions = normalizeCatalog(raw, eventId); + assert(sessions.length > 0, 'Expected fixture to contain sessions'); + + await mkdir(cacheDir, { recursive: true }); + await mkdir(emptyCacheDir, { recursive: true }); + await mkdir(workDir, { recursive: true }); + await writeFile(join(cacheDir, `${eventId}-sessions.json`), JSON.stringify(sessions)); + await writeFile(join(cacheDir, `${eventId}-meta.json`), JSON.stringify({ + eventId, + fetchedAt: '2026-01-01T00:00:00.000Z', + checkedAt: '2026-01-01T00:00:00.000Z', + nextCheckAt: '2099-01-01T00:00:00.000Z', + sessionCount: sessions.length, + etag: '"fixture"', + lastCheckStatus: 'failed', + consecutiveFailures: 2, + }, null, 2)); + await writeFile(noNetwork, "globalThis.fetch = () => { throw new Error(' network is disabled in smoke:m365'); };\n"); + + const built = await run([ + 'scripts/build-m365-plugin.mjs', + '--sessions-dir', cacheDir, + '--event', eventId, + '--out', pluginDir, + '--plugin-version', '1.0.0', + ...(options.package ? ['--package'] : []), + ], { cwd: cliRoot, timeout: options.package ? 600_000 : commandTimeoutMs }); + assert(built.code === 0, `build-m365-plugin failed:\n${built.stdout}${built.stderr}`); + + if (options.package) { + await unzip(join(`${pluginDir}-app`, 'appPackage', 'build', 'appPackage.zip'), unzippedDir); + const appManifest = JSON.parse(await readFile(join(unzippedDir, 'manifest.json'), 'utf8')); + assert( + appManifest.agentSkills?.some((s) => s.folder === './skills/microsoft-build'), + 'Expected the app package manifest to register skills/microsoft-build', + ); + const builtFiles = await listFiles(builtSkillDir); + const packedFiles = await listFiles(skillDir); + assert( + packedFiles.join(',') === builtFiles.join(','), + `App package skill files differ from the plugin folder:\n zip: ${packedFiles.join(', ')}\n plugin: ${builtFiles.join(', ')}`, + ); + for (const file of builtFiles) { + const [a, b] = await Promise.all([readFile(join(builtSkillDir, file)), readFile(join(skillDir, file))]); + assert(a.equals(b), `${file} in the app package differs from the plugin folder`); + } + } + + const dataFiles = (await readdir(dataDir)).sort(); + assert( + dataFiles.join(',') === `${eventId}-meta.json,${eventId}-sessions.json`, + `Unexpected data files: ${dataFiles.join(', ')}`, + ); + for (const file of ['SKILL.md', 'scripts/msevents.mjs', ...dataFiles.map((f) => `data/${f}`)]) { + const text = await readFile(join(skillDir, file), 'utf8'); + assert(!text.includes('\r'), `${file} must use LF line endings`); + if (file === 'SKILL.md') { + assert(!text.includes('{{'), 'SKILL.md has unfilled placeholders'); + assert(text.includes('2026-01-01'), 'Expected SKILL.md to show the snapshot date from the cache meta file'); + assert(text.includes(`| \`${eventId}\` | ${sessions.length} |`), 'Expected SKILL.md to list the bundled event'); + const repoSkill = (await readFile('../skills/microsoft-build/SKILL.md', 'utf8')).replace(/\r\n?/g, '\n'); + for (const heading of repoSkill.match(/^## .+$/gm)) { + assert(text.includes(`\n${heading}\n`), `Expected SKILL.md to keep the repo skill's "${heading}" section`); + } + assert(text.includes('node scripts/msevents.mjs session [ID]'), 'Expected workflow commands to use the bundled script'); + assert(!/npx -y|\*\*Without CLI\*\*|-session-info/.test(text), 'Expected SKILL.md to have no online catalog steps'); + } + } + const meta = JSON.parse(await readFile(join(dataDir, `${eventId}-meta.json`), 'utf8')); + assert(meta.fetchedAt === '2026-01-01T00:00:00.000Z', 'Expected fetchedAt from the cache meta file'); + assert(!('lastCheckStatus' in meta) && !('etag' in meta), 'Expected revalidation state to be dropped from the frozen meta'); + + await setReadOnly(dataDir, true); + dataLocked = true; + + // The banner forces offline mode and the bundled data folder, whatever the environment says. + const snapshot = (...args) => run( + ['--import', pathToFileURL(noNetwork).href, join(skillDir, 'scripts', 'msevents.mjs'), ...args], + { cwd: workDir, env: { ...process.env, MSEVENTS_OFFLINE: '0', MSEVENTS_CACHE_DIR: emptyCacheDir } }, + ); + const live = (...args) => run(['dist/index.js', ...args], { + cwd: cliRoot, + env: { ...process.env, MSEVENTS_CACHE_DIR: cacheDir, MSEVENTS_OFFLINE: '' }, + }); + + const sessionCode = sessions[0].sessionCode; + const speaker = sessions.find((s) => s.speakers)?.speakers.split(',')[0].trim(); + assert(speaker, 'Expected a fixture session with speakers'); + + const sameAsLive = [ + ['sessions', '--query', 'Foundry', '--event', eventId, '--json'], + ['sessions', '--query', 'Foundry', '--event', eventId], + ['sessions', '--tech', 'Azure', '--type', 'breakout', '--event', eventId, '--limit', '5', '--json'], + ['sessions', '--speaker', speaker, '--event', eventId, '--json'], + ['sessions', '--query', sessionCode.toLowerCase(), '--event', eventId, '--json'], + ['session', sessionCode, '--event', eventId, '--json'], + ['session', sessionCode, '--event', eventId], + ['sessions', '--event', eventId], + ['sessions', '--query', 'agents', '--event', eventId, '--limit', '500'], + ['--version'], + ]; + const outputs = []; + for (const args of sameAsLive) { + const [a, b] = await Promise.all([snapshot(...args), live(...args)]); + const label = args.join(' '); + assert(a.code === b.code, `Exit code differs for "${label}": snapshot ${a.code}, CLI ${b.code}\n${a.stderr}`); + assert(a.stdout === b.stdout, `stdout differs for "${label}"`); + assert(a.stderr === b.stderr, `stderr differs for "${label}":\n${a.stderr}\n---\n${b.stderr}`); + outputs.push(a); + } + assert(JSON.parse(outputs[0].stdout).length > 0, 'Expected the Foundry search to return sessions'); + assert(JSON.parse(outputs[3].stdout).length > 0, `Expected sessions for speaker ${speaker}`); + assert(JSON.parse(outputs[5].stdout).sessionCode === sessionCode, `Expected session ${sessionCode}`); + assert(outputs[7].code !== 0, 'Expected sessions without a filter to fail validation'); + assert(outputs[8].code === 0 && outputs[8].stderr.includes('clamping'), 'Expected --limit 500 to be clamped'); + + const refresh = await snapshot('refresh'); + assert(refresh.code === 1 && refresh.stderr.includes('MSEVENTS_OFFLINE'), `Expected refresh to fail offline:\n${refresh.stderr}`); + + const status = await snapshot('status', '--json'); + assert(status.code === 0, `status failed:\n${status.stderr}`); + const statuses = JSON.parse(status.stdout); + assert(statuses.every((s) => s.offline === true), 'Expected every status entry to report offline: true'); + const cached = statuses.filter((s) => s.meta); + assert( + cached.length === 1 && cached[0].eventId === eventId && cached[0].meta.sessionCount === sessions.length, + 'Expected status to list only the fixture event as cached', + ); + + const statusText = await snapshot('status'); + assert(statusText.stdout.startsWith('Offline (MSEVENTS_OFFLINE)'), `Expected an offline line in status:\n${statusText.stdout}`); + assert(!statusText.stdout.includes('last check failed'), 'Expected the frozen meta to drop the failed-check state'); + + const missing = await snapshot('sessions', '--query', 'agents', '--event', 'ignite-2025', '--json'); + assert(missing.code === 0 && JSON.parse(missing.stdout).length === 0, 'Expected no results for an event outside the snapshot'); + assert(missing.stderr.includes('no cached sessions for Microsoft Ignite 2025'), `Expected a missing-event notice:\n${missing.stderr}`); + + const all = await snapshot('sessions', '--query', 'Foundry', '--json'); + assert(all.code === 0 && JSON.parse(all.stdout).length > 0, 'Expected a search across all events to use the bundled event'); + + assert((await readdir(dataDir)).sort().join(',') === dataFiles.join(','), 'The bundled CLI changed the data folder'); + assert((await readdir(emptyCacheDir)).length === 0, 'The bundled CLI wrote to MSEVENTS_CACHE_DIR from the environment'); + + const source = options.package ? 'unzipped app package' : 'plugin folder'; + console.log(`M365 plugin smoke passed (${source}; ${sameAsLive.length} commands match the CLI).`); +} finally { + if (dataLocked) await setReadOnly(dataDir, false); + await rm(root, { recursive: true, force: true }); +} diff --git a/cli/src/commands/common.ts b/cli/src/commands/common.ts index 1e847b9..175ed21 100644 --- a/cli/src/commands/common.ts +++ b/cli/src/commands/common.ts @@ -1,4 +1,4 @@ -import { KNOWN_EVENTS } from '../config.js'; +import { KNOWN_EVENTS, OFFLINE_ENV_VAR, isOfflineMode } from '../config.js'; import { fetchAndCache, getAllCachedSessions, @@ -6,7 +6,7 @@ import { readMeta, readSessions, } from '../data/cache.js'; -import type { Session } from '../contracts.js'; +import type { EventConfig, Session } from '../contracts.js'; import { FetchError } from '../errors.js'; export function validateEventId(eventId: string): boolean { @@ -36,6 +36,20 @@ export function validateLimit(raw: string): number | null { return parsed; } +async function readCacheOffline(events: EventConfig[]): Promise { + const sessions: Session[] = []; + const missing: string[] = []; + for (const event of events) { + const cached = await readSessions(event.id); + if (cached.length === 0) missing.push(event.name); + sessions.push(...cached); + } + if (missing.length > 0) { + process.stderr.write(`Offline (${OFFLINE_ENV_VAR}): no cached sessions for ${missing.join(', ')}.\n`); + } + return sessions; +} + export async function ensureCache(eventFilter?: string): Promise { let missingCacheHeaderPrinted = false; const availableSessions: Session[] = []; @@ -43,6 +57,8 @@ export async function ensureCache(eventFilter?: string): Promise { ? KNOWN_EVENTS.filter((event) => event.id === eventFilter) : KNOWN_EVENTS; + if (isOfflineMode()) return readCacheOffline(events); + for (const event of events) { const cachedSessions = await readSessions(event.id); const meta = await readMeta(event.id); diff --git a/cli/src/commands/refresh.ts b/cli/src/commands/refresh.ts index 1a35118..beddd4e 100644 --- a/cli/src/commands/refresh.ts +++ b/cli/src/commands/refresh.ts @@ -1,9 +1,14 @@ -import { KNOWN_EVENTS } from '../config.js'; +import { KNOWN_EVENTS, OFFLINE_ENV_VAR, isOfflineMode } from '../config.js'; import { fetchAndCache } from '../data/cache.js'; import { FetchError } from '../errors.js'; import { validateEventId } from './common.js'; export async function refresh(eventFilter?: string, force: boolean = false): Promise { + if (isOfflineMode()) { + console.error(`refresh isn't available because ${OFFLINE_ENV_VAR} is set; the cached session data can't be updated.`); + process.exitCode = 1; + return; + } if (eventFilter && !validateEventId(eventFilter)) return; const events = eventFilter diff --git a/cli/src/commands/status.ts b/cli/src/commands/status.ts index 3a8d14a..a37a310 100644 --- a/cli/src/commands/status.ts +++ b/cli/src/commands/status.ts @@ -1,7 +1,8 @@ +import { isOfflineMode } from '../config.js'; import { getCacheStatus } from '../data/cache.js'; import { formatStatus } from '../output/format.js'; export async function status(opts: { json?: boolean }): Promise { const statuses = await getCacheStatus(); - console.log(formatStatus(statuses, opts.json ?? false)); + console.log(formatStatus(statuses, opts.json ?? false, isOfflineMode())); } diff --git a/cli/src/config.ts b/cli/src/config.ts index c86f33d..dfb8230 100644 --- a/cli/src/config.ts +++ b/cli/src/config.ts @@ -24,3 +24,14 @@ export const KNOWN_EVENTS: EventConfig[] = [ ]; export const DEFAULT_LIMIT = 10; + +export const OFFLINE_ENV_VAR = 'MSEVENTS_OFFLINE'; + +/** + * Offline mode (MSEVENTS_OFFLINE=1 or true) reads cached sessions only: it never + * fetches catalogs or writes to the cache, so the cache folder can be read-only. + */ +export function isOfflineMode(): boolean { + const value = process.env[OFFLINE_ENV_VAR]?.trim().toLowerCase(); + return value === '1' || value === 'true'; +} diff --git a/cli/src/index.ts b/cli/src/index.ts index ed7be7b..a98b21e 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -11,7 +11,12 @@ import { KNOWN_EVENTS } from './config.js'; const knownIds = KNOWN_EVENTS.map((e) => e.id).join(', '); +// Set by bundlers that can't ship package.json next to the code (see scripts/build-m365-plugin.mjs). +declare const __MSEVENTS_VERSION__: string | undefined; + function readPackageVersion(): string { + if (typeof __MSEVENTS_VERSION__ === 'string') return __MSEVENTS_VERSION__; + const packageJson: unknown = JSON.parse( readFileSync(new URL('../package.json', import.meta.url), 'utf-8'), ); diff --git a/cli/src/output/format.ts b/cli/src/output/format.ts index ad7470b..d07f81d 100644 --- a/cli/src/output/format.ts +++ b/cli/src/output/format.ts @@ -1,4 +1,5 @@ import type { Session, SearchResult, CacheMeta } from '../contracts.js'; +import { OFFLINE_ENV_VAR } from '../config.js'; export function formatSessionShort(s: Session): string { const parts = [`[${s.sessionCode}] ${s.title}`]; @@ -78,10 +79,13 @@ function formatAge(ms: number): string { export function formatStatus( statuses: Array<{ eventId: string; meta: CacheMeta | null }>, json: boolean, + offline = false, ): string { - if (json) return JSON.stringify(statuses, null, 2); + if (json) { + return JSON.stringify(offline ? statuses.map((s) => ({ ...s, offline: true })) : statuses, null, 2); + } if (statuses.length === 0) return 'No known events configured.'; - return statuses + const lines = statuses .map(({ eventId, meta }) => { if (!meta) return ` ${eventId}: not cached`; const cachedAge = formatAge(Date.now() - new Date(meta.fetchedAt).getTime()); @@ -90,6 +94,9 @@ export function formatStatus( : ''; const status = meta.lastCheckStatus === 'failed' ? ', last check failed' : ''; return ` ${eventId}: ${meta.sessionCount} sessions, cached ${cachedAge}${checkedAge}${status}`; - }) - .join('\n'); + }); + if (offline) { + lines.unshift(`Offline (${OFFLINE_ENV_VAR}): cached data is read-only and won't be updated.`); + } + return lines.join('\n'); } diff --git a/cli/test/offline.test.ts b/cli/test/offline.test.ts new file mode 100644 index 0000000..8e5b63d --- /dev/null +++ b/cli/test/offline.test.ts @@ -0,0 +1,146 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { isOfflineMode, KNOWN_EVENTS } from '../src/config.js'; +import { ensureCache } from '../src/commands/common.js'; +import { refresh } from '../src/commands/refresh.js'; +import { formatStatus } from '../src/output/format.js'; +import type { CacheMeta, Session } from '../src/contracts.js'; + +function session(event: string, sessionCode: string): Session { + return { + sessionCode, + title: `${event} ${sessionCode}`, + description: '', + speakers: '', + timeSlot: '', + startDateTime: '', + endDateTime: '', + location: '', + level: '', + type: '', + topic: '', + solutionArea: '', + product: '', + languages: '', + tags: '', + deliveryTypes: '', + viewingOptions: '', + hasLiveStream: false, + hasOnDemand: false, + relatedSessionCodes: '', + slideDeck: '', + onDemand: '', + event, + }; +} + +const dueMeta: CacheMeta = { + eventId: 'build-2025', + fetchedAt: '2020-01-01T00:00:00.000Z', + checkedAt: '2020-01-01T00:00:00.000Z', + nextCheckAt: '2020-01-01T00:20:00.000Z', + sessionCount: 1, +}; + +function stderrOutput(): string { + return vi.mocked(process.stderr.write).mock.calls.map(([chunk]) => String(chunk)).join(''); +} + +describe('isOfflineMode', () => { + afterEach(() => { + delete process.env.MSEVENTS_OFFLINE; + }); + + it.each(['1', 'true', 'TRUE', ' 1 '])('is on for %j', (value) => { + process.env.MSEVENTS_OFFLINE = value; + expect(isOfflineMode()).toBe(true); + }); + + it.each([undefined, '', '0', 'false', 'yes'])('is off for %j', (value) => { + if (value === undefined) delete process.env.MSEVENTS_OFFLINE; + else process.env.MSEVENTS_OFFLINE = value; + expect(isOfflineMode()).toBe(false); + }); +}); + +describe('offline mode (MSEVENTS_OFFLINE)', () => { + let cacheDir: string; + const fetchMock = vi.fn(); + + beforeEach(async () => { + cacheDir = await mkdtemp(join(tmpdir(), 'msevents-offline-test-')); + process.env.MSEVENTS_CACHE_DIR = cacheDir; + process.env.MSEVENTS_OFFLINE = '1'; + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); + vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + vi.spyOn(console, 'error').mockImplementation(() => {}); + await writeFile(join(cacheDir, 'build-2025-sessions.json'), JSON.stringify([session('build-2025', 'BRK100')])); + await writeFile(join(cacheDir, 'build-2025-meta.json'), JSON.stringify(dueMeta)); + }); + + afterEach(async () => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + delete process.env.MSEVENTS_CACHE_DIR; + delete process.env.MSEVENTS_OFFLINE; + process.exitCode = undefined; + await rm(cacheDir, { recursive: true, force: true }); + }); + + it('reads cached sessions without revalidating a cache that is due', async () => { + const sessions = await ensureCache('build-2025'); + + expect(sessions.map((s) => s.sessionCode)).toEqual(['BRK100']); + expect(fetchMock).not.toHaveBeenCalled(); + expect(stderrOutput()).toBe(''); + expect(JSON.parse(await readFile(join(cacheDir, 'build-2025-meta.json'), 'utf-8'))).toEqual(dueMeta); + }); + + it('skips events with no cache, notes them once, and writes nothing', async () => { + const sessions = await ensureCache(); + + expect(sessions.map((s) => s.sessionCode)).toEqual(['BRK100']); + expect(fetchMock).not.toHaveBeenCalled(); + const uncached = KNOWN_EVENTS.filter((e) => e.id !== 'build-2025').map((e) => e.name); + expect(uncached.length).toBeGreaterThan(0); + expect(stderrOutput()).toBe(`Offline (MSEVENTS_OFFLINE): no cached sessions for ${uncached.join(', ')}.\n`); + expect((await readdir(cacheDir)).sort()).toEqual(['build-2025-meta.json', 'build-2025-sessions.json']); + }); + + it('returns no sessions for an uncached event', async () => { + expect(await ensureCache('ignite-2025')).toEqual([]); + expect(fetchMock).not.toHaveBeenCalled(); + expect(stderrOutput()).toContain('no cached sessions for Microsoft Ignite 2025'); + }); + + it('makes refresh fail without fetching', async () => { + await refresh('build-2025', true); + + expect(process.exitCode).toBe(1); + expect(fetchMock).not.toHaveBeenCalled(); + expect(vi.mocked(console.error).mock.calls[0]?.[0]).toContain('MSEVENTS_OFFLINE is set'); + }); +}); + +describe('formatStatus in offline mode', () => { + const statuses = [ + { eventId: 'build-2025', meta: { ...dueMeta, lastCheckStatus: 'updated' as const } }, + { eventId: 'ignite-2025', meta: null }, + ]; + + it('adds an offline line to text output', () => { + const lines = formatStatus(statuses, false, true).split('\n'); + expect(lines[0]).toBe("Offline (MSEVENTS_OFFLINE): cached data is read-only and won't be updated."); + expect(lines.slice(1)).toEqual(formatStatus(statuses, false).split('\n')); + }); + + it('marks every JSON entry as offline', () => { + expect(JSON.parse(formatStatus(statuses, true, true))).toEqual( + statuses.map((s) => ({ ...s, offline: true })), + ); + expect(JSON.parse(formatStatus(statuses, true))).toEqual(statuses); + }); +});