Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
123 changes: 38 additions & 85 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 @@ -186,66 +191,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 +236,48 @@ 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 {
// No existing package — create fresh zip with default icons
await importAppPackage(token, createDefaultZip(manifestJson), true);
return;
Comment thread
heyitsaamir marked this conversation as resolved.
Outdated
}

// 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
58 changes: 52 additions & 6 deletions packages/cli/src/commands/app/manifest/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ import { CliError } from '../../../utils/errors.js';
import { isAutoConfirm } from '../../../utils/interactive.js';
import { logger } from '../../../utils/logger.js';
import { createSilentSpinner } from '../../../utils/spinner.js';
import { bumpPatchVersion, compareVersions } from '../../../utils/version.js';

export interface UploadResult {
version: string;
versionBumped: boolean;
}

/**
* Download manifest from an app package. Saves to file or prints to stdout.
Expand Down Expand Up @@ -49,15 +55,17 @@ export async function downloadManifest(

/**
* Upload a local manifest.json to update an existing app.
* Reads the file, validates it's a Teams manifest, and uploads via TDP API.
* Throws on failure.
* Reads the file, validates it's a Teams manifest, and uploads via TDP import.
* Optionally auto-bumps the version if content changed but version didn't.
* Returns upload result, or undefined if the user cancelled.
*/
export async function uploadManifestFromFile(
token: string,
teamsAppId: string,
filePath: string,
silent = false
): Promise<void> {
silent = false,
autoBumpVersion = true
): Promise<UploadResult | undefined> {
const resolved = path.resolve(filePath);

let raw: string;
Expand Down Expand Up @@ -105,13 +113,49 @@ export async function uploadManifestFromFile(
logger.warn(pc.yellow(`Manifest is missing fields: ${missing.join(', ')}`));
if (!isAutoConfirm()) {
const proceed = await confirm({ message: 'Upload anyway?', default: false });
if (!proceed) return;
if (!proceed) return undefined;
}
}

// Auto-bump version if enabled and version is parseable
let versionBumped = false;
if (autoBumpVersion && manifest.version) {
try {
const packageBuffer = await downloadAppPackage(token, teamsAppId);
const zip = new AdmZip(packageBuffer);
const serverEntry = zip.getEntry('manifest.json');

if (serverEntry) {
const serverManifest = JSON.parse(serverEntry.getData().toString('utf-8'));
const serverVersion: string = serverManifest.version ?? '';
const cmp = compareVersions(manifest.version, serverVersion);

if (cmp === 0) {
// Same version — bump if content actually changed
const serverCopy = { ...serverManifest, version: undefined };
const localCopy = { ...(manifest as Record<string, unknown>), version: undefined };
const contentChanged = JSON.stringify(serverCopy) !== JSON.stringify(localCopy);
Comment thread
heyitsaamir marked this conversation as resolved.
Outdated

if (contentChanged) {
const bumped = bumpPatchVersion(manifest.version);
if (bumped) {
if (!silent) {
logger.info(pc.dim(`Version auto-bumped: ${manifest.version} → ${bumped}`));
}
manifest = { ...manifest, version: bumped };
versionBumped = true;
}
}
}
}
} catch {
// Failed to download/compare (e.g. first upload) — skip auto-bumping
}
}

const spinner = createSilentSpinner('Uploading manifest...', silent).start();
try {
await uploadManifest(token, teamsAppId, manifest);
await uploadManifest(token, teamsAppId, JSON.stringify(manifest, null, 2));
} catch (error) {
spinner.error({ text: 'Upload failed' });
throw error;
Expand All @@ -123,4 +167,6 @@ export async function uploadManifestFromFile(
pc.green(`Manifest from ${pc.bold(resolved)} applied to app ${pc.bold(teamsAppId)}`)
);
}

return { version: manifest.version, versionBumped };
Comment thread
heyitsaamir marked this conversation as resolved.
}
23 changes: 20 additions & 3 deletions packages/cli/src/commands/app/manifest/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,23 @@ import { uploadManifestFromFile } from './actions.js';
interface ManifestUploadOutput {
teamsAppId: string;
filePath: string;
version?: string;
versionBumped?: boolean;
}

export const manifestUploadCommand = new Command('upload')
.description('Upload a manifest.json to update an existing Teams app')
.argument('<file-path>', 'Path to manifest.json file')
.argument('[appId]', 'Teams app ID (prompted if not provided)')
.option('--no-bump-version', '[OPTIONAL] Disable automatic version bumping')
.option('--json', '[OPTIONAL] Output as JSON')
.action(
wrapAction(
async (filePathArg: string, appIdArg: string | undefined, options: { json?: boolean }) => {
async (
filePathArg: string,
appIdArg: string | undefined,
options: { json?: boolean; bumpVersion?: boolean }
) => {
let teamsAppId: string;
let token: string;

Expand Down Expand Up @@ -45,10 +52,20 @@ export const manifestUploadCommand = new Command('upload')
token = picked.token;
}

await uploadManifestFromFile(token, teamsAppId, filePathArg, options.json);
const result = await uploadManifestFromFile(
token,
teamsAppId,
filePathArg,
options.json,
options.bumpVersion !== false
);

if (options.json) {
outputJson({ teamsAppId, filePath: filePathArg } satisfies ManifestUploadOutput);
outputJson({
teamsAppId,
filePath: filePathArg,
...(result ? { version: result.version, versionBumped: result.versionBumped } : {}),
} satisfies ManifestUploadOutput);
}
}
)
Expand Down
26 changes: 24 additions & 2 deletions packages/cli/src/commands/app/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ interface UpdateOptions {
termsUrl?: string;
colorIcon?: string;
outlineIcon?: string;
webAppInfoId?: string;
webAppInfoResource?: string;
json?: boolean;
}

Expand All @@ -60,6 +62,8 @@ interface AppUpdateOutput {
termsOfUseUrl?: string;
colorIcon?: boolean;
outlineIcon?: boolean;
webApplicationInfoId?: string;
webApplicationInfoResource?: string;
};
}

Expand Down Expand Up @@ -245,6 +249,8 @@ export const appUpdateCommand = new Command('update')
.option('--terms-url <url>', '[OPTIONAL] Set the terms of use URL (HTTPS required)')
.option('--color-icon <path>', '[OPTIONAL] Set the color icon (192x192 PNG)')
.option('--outline-icon <path>', '[OPTIONAL] Set the outline icon (32x32 PNG)')
.option('--web-app-info-id <id>', '[OPTIONAL] Set the webApplicationInfo client ID')
.option('--web-app-info-resource <uri>', '[OPTIONAL] Set the webApplicationInfo resource URI (max 100 chars)')
.option('--json', '[OPTIONAL] Output as JSON')
.action(
wrapAction(async (appIdArg: string | undefined, options: UpdateOptions) => {
Expand All @@ -263,7 +269,9 @@ export const appUpdateCommand = new Command('update')
options.privacyUrl !== undefined ||
options.termsUrl !== undefined ||
options.colorIcon !== undefined ||
options.outlineIcon !== undefined;
options.outlineIcon !== undefined ||
options.webAppInfoId !== undefined ||
options.webAppInfoResource !== undefined;

// --json requires mutation flags
if (options.json && !hasMutationFlags) {
Expand All @@ -273,13 +281,19 @@ export const appUpdateCommand = new Command('update')
);
}

// Validate icons upfront (before auth/API calls)
// Validate inputs upfront (before auth/API calls)
const colorIconData = options.colorIcon
? readAndValidateIcon(options.colorIcon, 192)
: undefined;
const outlineIconData = options.outlineIcon
? readAndValidateIcon(options.outlineIcon, 32)
: undefined;
if (options.webAppInfoResource !== undefined && options.webAppInfoResource.length > 100) {
throw new CliError(
'VALIDATION_FORMAT',
'webApplicationInfo resource URI must be 100 characters or less.'
);
}
Comment thread
heyitsaamir marked this conversation as resolved.
Outdated

// Interactive mode (no appId, no mutation flags): picker loop
if (!appIdArg && !hasMutationFlags) {
Expand Down Expand Up @@ -464,6 +478,14 @@ export const appUpdateCommand = new Command('update')
allUpdates.termsOfUseUrl = options.termsUrl;
}

if (options.webAppInfoId !== undefined) {
allUpdates.webApplicationInfoId = options.webAppInfoId;
}

if (options.webAppInfoResource !== undefined) {
allUpdates.webApplicationInfoResource = options.webAppInfoResource;
}

// Apply basic info updates (endpoint and icons use separate API calls)
const { endpoint: _ep, colorIcon: _ci, outlineIcon: _oi, ...basicInfoUpdates } = allUpdates;
if (Object.keys(basicInfoUpdates).length > 0) {
Expand Down
Loading
Loading