Skip to content
Merged
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
16 changes: 16 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "teams-skills",
"owner": {
"name": "Microsoft",
"url": "https://github.com/microsoft"
},
"plugins": [
{
"name": "teams-sdk",
"description": "Create and manage Microsoft Teams bots using the Teams CLI. Covers bot application development (scaffolding bot code), infrastructure setup (bot registration, credentials), SSO configuration, and troubleshooting.",
"category": "development",
"source": "./plugins/teams-sdk",
"homepage": "https://github.com/microsoft/teams-sdk"
}
]
}
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

106 changes: 0 additions & 106 deletions packages/cli/skills/README.md

This file was deleted.

133 changes: 47 additions & 86 deletions packages/cli/src/apps/api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import AdmZip from 'adm-zip';
import fs from 'node:fs';
import path from 'node:path';
import type { AppSummary, AppDetails, AppBot } from './types.js';
import { importAppPackage } from './tdp.js';
import { apiFetch } from '../utils/http.js';
import { CliError } from '../utils/errors.js';
import { staticsDir } from '../project/paths.js';

/**
* Teams app manifest.json structure (subset of fields we care about)
Expand Down Expand Up @@ -130,7 +135,12 @@ export async function downloadAppPackage(token: string, appId: string): Promise<
});

if (!response.ok) {
throw new Error(`Failed to download app package: ${response.status} ${response.statusText}`);
throw new CliError(
'API_ERROR',
`Failed to download app package: ${response.status} ${response.statusText}`,
undefined,
response.status
);
}

// Response is a JSON-encoded base64 string (with quotes)
Expand Down Expand Up @@ -186,66 +196,6 @@ export async function updateAppDetails(
return response.json();
}

/**
* Transform a Teams manifest.json to AppDetails format for API upload.
*/
function manifestToAppDetails(manifest: TeamsManifest): Partial<AppDetails> {
const details: Partial<AppDetails> = {
appId: manifest.id,
manifestVersion: manifest.manifestVersion,
version: manifest.version,
shortName: manifest.name.short,
longName: manifest.name.full ?? manifest.name.short,
shortDescription: manifest.description.short,
longDescription: manifest.description.full ?? manifest.description.short,
developerName: manifest.developer.name,
websiteUrl: manifest.developer.websiteUrl,
privacyUrl: manifest.developer.privacyUrl,
termsOfUseUrl: manifest.developer.termsOfUseUrl,
};

if (manifest.developer.mpnId) {
details.mpnId = manifest.developer.mpnId;
}

if (manifest.accentColor) {
details.accentColor = manifest.accentColor;
}

if (manifest.bots) {
details.bots = manifest.bots.map((bot) => ({
botId: bot.botId,
scopes: bot.scopes,
}));
}

if (manifest.webApplicationInfo?.id) {
details.webApplicationInfoId = manifest.webApplicationInfo.id;
}

// Pass through other manifest fields that map directly
const passthroughFields = [
'staticTabs',
'configurableTabs',
'composeExtensions',
'permissions',
'validDomains',
'devicePermissions',
'activities',
'meetingExtensionDefinition',
'authorization',
'localizationInfo',
];

for (const field of passthroughFields) {
if (manifest[field] !== undefined) {
details[field] = manifest[field];
}
}

return details;
}

/**
* Upload an icon to a Teams app via TDP.
* Two-step process: upload bytes, then write the returned URL back to the app definition.
Expand Down Expand Up @@ -291,40 +241,51 @@ export async function uploadIcon(
}
}

/**
* Create a default app package zip with the given manifest and placeholder icons.
*/
function createDefaultZip(manifestJson: string): Buffer {
const zip = new AdmZip();
zip.addFile('manifest.json', Buffer.from(manifestJson, 'utf-8'));
zip.addFile('color.png', fs.readFileSync(path.join(staticsDir, 'color.png')));
zip.addFile('outline.png', fs.readFileSync(path.join(staticsDir, 'outline.png')));
return zip.toBuffer();
}

/**
* Upload a manifest.json to update an existing app.
* Uses read-modify-write pattern to preserve server-side fields.
* Downloads the current app package (preserving icons), replaces manifest.json,
* and re-imports via TDP's import endpoint with overwrite.
*/
export async function uploadManifest(
token: string,
teamsAppId: string,
manifest: TeamsManifest
): Promise<AppDetails> {
// 1. Fetch current app details to preserve server-only fields (icons, etc.)
const currentDetails = await fetchAppDetailsV2(token, teamsAppId);

// 2. Transform manifest to AppDetails format
const manifestDetails = manifestToAppDetails(manifest);
manifestJson: string
): Promise<void> {
let zipBuffer: Buffer;

// 3. Merge: manifest fields override, but preserve server-only fields
const updatedDetails = { ...currentDetails, ...manifestDetails };
try {
// Download existing package to preserve icons
zipBuffer = await downloadAppPackage(token, teamsAppId);
} catch (error) {
// Only fall back to default zip on 404 (no existing package)
if (error instanceof CliError && error.statusCode === 404) {
await importAppPackage(token, createDefaultZip(manifestJson), true);
return;
}
throw error;
}

// 4. POST full object back
const response = await apiFetch(`${TDP_BASE_URL}/appdefinitions/v2/${teamsAppId}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(updatedDetails),
});
// Build new zip: copy all entries except manifest.json, then add updated manifest
const oldZip = new AdmZip(zipBuffer);
const newZip = new AdmZip();

if (!response.ok) {
const errorText = await response.text();
throw new Error(
`Failed to upload manifest: ${response.status} ${response.statusText}\n${errorText}`
);
for (const entry of oldZip.getEntries()) {
if (entry.entryName === 'manifest.json') continue;
newZip.addFile(entry.entryName, entry.getData(), entry.comment, entry.attr);
}

return response.json();
newZip.addFile('manifest.json', Buffer.from(manifestJson, 'utf-8'));

await importAppPackage(token, newZip.toBuffer(), true);
}
9 changes: 7 additions & 2 deletions packages/cli/src/apps/tdp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,13 @@ export interface BotRegistration {
name: string;
}

export async function importAppPackage(token: string, zipBuffer: Buffer): Promise<ImportedApp> {
const response = await apiFetch(`${TDP_BASE_URL}/appdefinitions/v2/import`, {
export async function importAppPackage(
token: string,
zipBuffer: Buffer,
overwrite?: boolean
): Promise<ImportedApp> {
const query = overwrite ? '?overwriteIfAppAlreadyExists=true' : '';
const response = await apiFetch(`${TDP_BASE_URL}/appdefinitions/v2/import${query}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
Expand Down
Loading