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
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
59 changes: 53 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,50 @@ 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 { version: _sv, ...serverCopy } = serverManifest;
const { version: _lv, ...localCopy } = manifest;
const stableStringify = (obj: unknown) => JSON.stringify(obj, Object.keys(obj as object).sort());
const contentChanged = stableStringify(serverCopy) !== stableStringify(localCopy);

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 +168,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
Loading
Loading