Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/mobile-apps-script-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: mobile-apps-script-tests

on:
pull_request:
branches:
- main
paths:
- "plugins/mobile-apps/**"
- ".github/workflows/mobile-apps-script-tests.yml"

jobs:
test-mobile-apps-version-check:
name: test-mobile-apps-version-check
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0

- name: setup-node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 20

# Network behavior is mocked; CI validates release parsing and update
# guidance without depending on GitHub availability or current versions.
- name: test-version-check
run: node --test plugins/mobile-apps/scripts/tests/check-version.test.js
4 changes: 3 additions & 1 deletion plugins/mobile-apps/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

This file provides guidance to AI Agents when working with the **mobile-app** plugin.

> **Status:** v0 — 23 skills + 5 agents authored. The latest Expo standalone template snapshot is bundled under `template/`. Read [README.md](./README.md) for the command list.
> **Status:** v0 — 24 skills + 5 agents authored. The latest Expo standalone template snapshot is bundled under `template/`. Read [README.md](./README.md) for the command list.

## What This Plugin Is

Expand Down Expand Up @@ -73,6 +73,7 @@ Do not add preparation rewrites for `scheme`, `package`, `bundleIdentifier`, `sr
- `DONE_WITH_CONCERNS` requires at least one concern. If none, use `DONE`.
- Special early-return signals (`INDUSTRY_CONFIRM_REQUESTED:`, `DESIGN_VIBE_REQUESTED:`) pre-date this protocol and remain in effect — they are special-cased "ask the user one question and re-spawn me" handoffs, not terminal returns.
- The canonical orchestrator handler lives in [`skills/create-mobile-app/SKILL.md`](./skills/create-mobile-app/SKILL.md) Step 3.0. Future skills that spawn agents should reference it rather than duplicating the switch.
13. **Package versions come from manifests** — Skills and agents must parse the active project's `package.json` before making package-version claims. Before project creation, use `template/package.json`. Never hardcode an Expo SDK generation or package pin in skill prose; use `npx expo install --check` for compatibility.

## Decisions made

Expand All @@ -84,6 +85,7 @@ Do not add preparation rewrites for `scheme`, `package`, `bundleIdentifier`, `sr
- ✅ Auth: `/create-mobile-app` resolves the tenant from the selected Power Platform environment (`scripts/resolve-environment.js`), writes that tenant to `auth.config.json`, then lets the user paste an app registration client ID, create one from the Power Apps Wrap page and paste it, or skip auth for later. `/set-app-registration-native` is a manual helper for the same Wrap-page + pasted-client-ID flow.
- ✅ `/add-native` v0 scope: camera, location, push, biometrics, secure-store (already in template)
- ✅ Template is supplied as a fresh `pa-wrap-tools/templates/expo-app-standalone` folder before `/create-mobile-app` runs; users materialize it with `degit`, run `npm install`, then invoke the skill from that folder. The skill validates/prepares the folder and runs `npx power-apps init`.
- ✅ Every top-level skill runs the fail-open `scripts/check-version.js` preflight through `shared/shared-instructions.md`. Remote checks are cached for seven days outside the app; CI verifies every skill retains the shared-instructions reference. `/version-check` uses the same release check, resolves the npm feed's `latest` native host version once, pins preview/apply commands to it, reads its safe ceiling from bundled `template/app.json`, and applies only approved sequential migrations within that ceiling. Straightforward `.rej` conflicts are merged with customization markers and pre-existing diffs preserved; ambiguous behavioral conflicts get one bounded retry before user escalation.
- ✅ `brand/` directory convention: `/design-system` (Step 6.75) writes `brand/design-system.md` (spec), `brand/tokens.ts` (importable Tamagui tokens), and `brand/design-system.html` (visual gallery). Screen-builders MUST read `brand/design-system.md` if present; `## Negatives` = HARD RULES. `/create-mobile-app` Step 9b imports `brand/tokens.ts` via `skills/design-system/references/tamagui-integration.md`. Projects without `brand/` fall back to `## Design Direction` only — no breakage.
- ✅ Offline profile creation is **author-only in v0.1** — `/setup-offline-profile` and `/enable-tables-offline` POST `mobileofflineprofile` / `mobileofflineprofileitem` / `mobileofflineprofileitemassociation` to Dataverse and write `offline-profile.json` to the project, but do NOT scaffold offline runtime code (SQLite store, sync engine, write queue) into the generated app. Runtime support is gated on upstream `@microsoft/power-apps-native-host` confirmation.
- ✅ Custom filter mode (`recorddistributioncriteria=3`, `profileitemrule` → `savedquery`) is **deferred to v0.5**. v0.1 supports Related-rows-only / All-records / Organization-rows radio options only.
Expand Down
1 change: 1 addition & 0 deletions plugins/mobile-apps/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ Example edit flows:
| `/list-connections` | ✅ v0 | Finds or creates a Power Platform connection ID, or resolves a solution connection reference, for `npx power-apps add-data-source`. Use when adding non-Dataverse connectors or re-binding after a 401. |
| `/edit-app` | ✅ v0 | Post-generation app editor — updates affected sections of `native-app-plan.md`, applies Dataverse/native/design/connector changes, rebuilds affected screens, runs verification, updates `memory-bank.md`, and regenerates `preview.html` when UI changed. `--plan-only` preserves the old docs-only behavior. |
| `/deploy` | ✅ v0 | Build + push — `npm run build` then `npx power-apps push` to the env in `power.config.json`. **Does not** drive `expo run:ios` or `expo run:android` (out of scope for v0). |
| `/version-check` | ✅ v0 | Checks for a newer complete plugin release, resolves the npm feed's latest native host version, previews with that exact version, smart-merges straightforward root-config conflicts, and runs approved updates only through the template version supported by the installed plugin. |
| `/open-wrap-url` | ✅ v0 | Opens the Wrap URL in browser for an app ID using `https://make.powerapps.com/environments/<envID>/wrap?appID=<appID>`. Requires both `--app-id` and `--env-id`. |
| `/report-issue` | ✅ v0 | Read-only diagnostic — collects env / Expo / Node versions, project context, recent errors, and renders a copy-paste-ready GitHub issue body. Sanitizes secrets. |
| `/design-system` | ✅ v0 | End-to-end design system — collects brand inputs (logo, brand doc, website, free text, canvas app, code app, Figma), runs a 3-style visual picker, writes `brand/design-system.md` + `brand/tokens.ts`, renders branded screen previews. Auto-invoked at Step 6.75 of `/create-mobile-app`; also standalone. |
Expand Down
161 changes: 161 additions & 0 deletions plugins/mobile-apps/scripts/check-version.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
#!/usr/bin/env node

/**
* Compares the installed mobile-app plugin version with the published main
* manifest and prints update commands only when a newer release exists. This
* check is fail-open so network access never blocks app work.
*/

'use strict';

const fs = require('fs');
const os = require('os');
const path = require('path');

const PLUGIN_MANIFEST_PATHS = ['.plugin/plugin.json', '.claude-plugin/plugin.json'];
const MARKETPLACE_NAME = 'power-platform-skills';
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const REMOTE_MANIFEST_URL =
'https://raw.githubusercontent.com/microsoft/power-platform-skills/main/plugins/mobile-apps/.plugin/plugin.json';

function compareSemver(localVersion, remoteVersion) {
const localParts = localVersion.split('.').map(Number);
const remoteParts = remoteVersion.split('.').map(Number);
for (let index = 0; index < 3; index += 1) {
if ((remoteParts[index] || 0) > (localParts[index] || 0)) return 1;
if ((remoteParts[index] || 0) < (localParts[index] || 0)) return -1;
}
return 0;
}
Comment on lines +21 to +29

function formatUpdateMessage(pluginName, localVersion, remoteVersion, marketplaceName) {
const qualifiedName = marketplaceName ? `${pluginName}@${marketplaceName}` : pluginName;
let message = `Plugin update available: ${pluginName} ${localVersion} -> ${remoteVersion}.\n`;
if (marketplaceName) {
message +=
`GitHub Copilot CLI:\n` +
` copilot plugin marketplace update ${marketplaceName}\n` +
` copilot plugin update ${qualifiedName}\n` +
`Claude Code:\n` +
` claude plugin marketplace update ${marketplaceName}\n` +
` claude plugin update ${qualifiedName}`;
} else {
message +=
`GitHub Copilot CLI: copilot plugin update ${qualifiedName}\n` +
`Claude Code: claude plugin update ${qualifiedName}`;
}
return message;
}

function firstExistingPath(root, relativePaths) {
for (const relativePath of relativePaths) {
const filePath = path.join(root, relativePath);
if (fs.existsSync(filePath)) return filePath;
}
return null;
}

async function fetchRemotePlugin(fetchImpl = fetch) {
const response = await fetchImpl(REMOTE_MANIFEST_URL, {
headers: { accept: 'application/json' },
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
throw new Error(`Remote manifest request failed with HTTP ${response.status}`);
}
return response.json();
}

function resolveCachePath(env = process.env, homeDirectory = os.homedir()) {
const cacheRoot = env.XDG_CACHE_HOME || env.LOCALAPPDATA || path.join(homeDirectory, '.cache');
return path.join(cacheRoot, 'power-platform-skills', 'mobile-app-version-check.json');
}

function readCache(cachePath) {
try {
return JSON.parse(fs.readFileSync(cachePath, 'utf8'));
} catch {
return null;
}
}

function isFresh(cache, now = Date.now()) {
return Boolean(
Number.isFinite(cache?.checkedAt) &&
now - cache.checkedAt >= 0 &&
now - cache.checkedAt < CACHE_TTL_MS
);
}

function writeCache(cachePath, remoteVersion, now = Date.now()) {
try {
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
fs.writeFileSync(
cachePath,
`${JSON.stringify({ checkedAt: now, remoteVersion: remoteVersion || null })}\n`,
'utf8'
);
} catch {
// Read-only homes must not turn an advisory check into a skill failure.
}
}

async function getRemoteVersion({
cachePath = resolveCachePath(),
fetchImpl = fetch,
now = Date.now(),
} = {}) {
const cache = readCache(cachePath);
if (isFresh(cache, now)) return cache.remoteVersion;

try {
const remotePlugin = await fetchRemotePlugin(fetchImpl);
if (!remotePlugin?.version) return cache?.remoteVersion || null;
writeCache(cachePath, remotePlugin.version, now);
return remotePlugin.version;
} catch {
// Cache failed attempts too, otherwise every skill invocation would wait on an offline network.
writeCache(cachePath, cache?.remoteVersion || null, now);
return cache?.remoteVersion || null;
}
}

if (require.main === module) {
void (async () => {
try {
const pluginRoot = path.resolve(__dirname, '..');
const pluginJsonPath = firstExistingPath(pluginRoot, PLUGIN_MANIFEST_PATHS);
if (!pluginJsonPath) return;

const localPlugin = JSON.parse(fs.readFileSync(pluginJsonPath, 'utf8'));
if (!localPlugin.version) return;

const remoteVersion = await getRemoteVersion();
if (!remoteVersion) return;

if (compareSemver(localPlugin.version, remoteVersion) > 0) {
console.log(
formatUpdateMessage(
localPlugin.name || 'mobile-app',
localPlugin.version,
remoteVersion,
MARKETPLACE_NAME
)
);
}
} catch {
// Plugin update discovery is advisory and must never block template checks.
}
})();
}

module.exports = {
CACHE_TTL_MS,
REMOTE_MANIFEST_URL,
compareSemver,
fetchRemotePlugin,
formatUpdateMessage,
getRemoteVersion,
isFresh,
resolveCachePath,
};
Loading