diff --git a/packages/cli/src/apps/api.ts b/packages/cli/src/apps/api.ts index 1ece50ddb..36da9c0c9 100644 --- a/packages/cli/src/apps/api.ts +++ b/packages/cli/src/apps/api.ts @@ -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) @@ -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) @@ -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 { - const details: Partial = { - 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. @@ -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 { - // 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 { + 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); } diff --git a/packages/cli/src/apps/tdp.ts b/packages/cli/src/apps/tdp.ts index 3f01f4478..1e36cfa43 100644 --- a/packages/cli/src/apps/tdp.ts +++ b/packages/cli/src/apps/tdp.ts @@ -36,8 +36,13 @@ export interface BotRegistration { name: string; } -export async function importAppPackage(token: string, zipBuffer: Buffer): Promise { - const response = await apiFetch(`${TDP_BASE_URL}/appdefinitions/v2/import`, { +export async function importAppPackage( + token: string, + zipBuffer: Buffer, + overwrite?: boolean +): Promise { + const query = overwrite ? '?overwriteIfAppAlreadyExists=true' : ''; + const response = await apiFetch(`${TDP_BASE_URL}/appdefinitions/v2/import${query}`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, diff --git a/packages/cli/src/commands/app/manifest/actions.ts b/packages/cli/src/commands/app/manifest/actions.ts index a5c41ee20..0ffc5e6fc 100644 --- a/packages/cli/src/commands/app/manifest/actions.ts +++ b/packages/cli/src/commands/app/manifest/actions.ts @@ -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. @@ -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 { + silent = false, + autoBumpVersion = true +): Promise { const resolved = path.resolve(filePath); let raw: string; @@ -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; @@ -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 }; } diff --git a/packages/cli/src/commands/app/manifest/upload.ts b/packages/cli/src/commands/app/manifest/upload.ts index 41d82c376..efc50854b 100644 --- a/packages/cli/src/commands/app/manifest/upload.ts +++ b/packages/cli/src/commands/app/manifest/upload.ts @@ -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('', '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; @@ -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); } } ) diff --git a/packages/cli/src/commands/app/update.ts b/packages/cli/src/commands/app/update.ts index 2f7955728..2f3691ee0 100644 --- a/packages/cli/src/commands/app/update.ts +++ b/packages/cli/src/commands/app/update.ts @@ -40,6 +40,8 @@ interface UpdateOptions { termsUrl?: string; colorIcon?: string; outlineIcon?: string; + webAppInfoId?: string; + webAppInfoResource?: string; json?: boolean; } @@ -60,6 +62,8 @@ interface AppUpdateOutput { termsOfUseUrl?: string; colorIcon?: boolean; outlineIcon?: boolean; + webApplicationInfoId?: string; + webApplicationInfoResource?: string; }; } @@ -245,6 +249,8 @@ export const appUpdateCommand = new Command('update') .option('--terms-url ', '[OPTIONAL] Set the terms of use URL (HTTPS required)') .option('--color-icon ', '[OPTIONAL] Set the color icon (192x192 PNG)') .option('--outline-icon ', '[OPTIONAL] Set the outline icon (32x32 PNG)') + .option('--web-app-info-id ', '[OPTIONAL] Set the webApplicationInfo client ID') + .option('--web-app-info-resource ', '[OPTIONAL] Set the webApplicationInfo resource URI (max 100 chars)') .option('--json', '[OPTIONAL] Output as JSON') .action( wrapAction(async (appIdArg: string | undefined, options: UpdateOptions) => { @@ -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) { @@ -273,13 +281,27 @@ 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) { + if (options.webAppInfoResource.length > 100) { + throw new CliError( + 'VALIDATION_FORMAT', + 'webApplicationInfo resource URI must be 100 characters or less.' + ); + } + if (!options.webAppInfoResource.startsWith('api://')) { + throw new CliError( + 'VALIDATION_FORMAT', + 'webApplicationInfo resource URI must start with "api://" (e.g., api://botid-).' + ); + } + } // Interactive mode (no appId, no mutation flags): picker loop if (!appIdArg && !hasMutationFlags) { @@ -464,6 +486,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) { diff --git a/packages/cli/src/utils/errors.ts b/packages/cli/src/utils/errors.ts index fd7e5bc55..f3b5369bc 100644 --- a/packages/cli/src/utils/errors.ts +++ b/packages/cli/src/utils/errors.ts @@ -37,7 +37,8 @@ export class CliError extends Error { constructor( public readonly code: ErrorCode, message: string, - public readonly suggestion?: string + public readonly suggestion?: string, + public readonly statusCode?: number ) { super(message); this.name = 'CliError'; diff --git a/packages/cli/src/utils/version.ts b/packages/cli/src/utils/version.ts new file mode 100644 index 000000000..9d7ab291d --- /dev/null +++ b/packages/cli/src/utils/version.ts @@ -0,0 +1,35 @@ +/** + * Increment the last numeric segment of a dotted version string. + * Returns null if the version can't be parsed. + */ +export function bumpPatchVersion(version: string): string | null { + const parts = version.split('.'); + if (parts.length < 2 || parts.length > 3) return null; + const last = parseInt(parts[parts.length - 1], 10); + if (isNaN(last)) return null; + parts[parts.length - 1] = String(last + 1); + return parts.join('.'); +} + +/** + * Compare two dotted version strings numerically. + * Returns 1 if a > b, -1 if a < b, 0 if equal, null if unparseable. + */ +export function compareVersions(a: string, b: string): number | null { + const numericSegment = /^\d+$/; + const aParts = a.split('.'); + const bParts = b.split('.'); + if (aParts.some((s) => !numericSegment.test(s)) || bParts.some((s) => !numericSegment.test(s))) { + return null; + } + const pa = aParts.map(Number); + const pb = bParts.map(Number); + const maxLen = Math.max(pa.length, pb.length); + for (let i = 0; i < maxLen; i++) { + const va = pa[i] ?? 0; + const vb = pb[i] ?? 0; + if (va > vb) return 1; + if (va < vb) return -1; + } + return 0; +} diff --git a/packages/cli/tests/manifest-upload-zip.test.ts b/packages/cli/tests/manifest-upload-zip.test.ts new file mode 100644 index 000000000..93c9b2265 --- /dev/null +++ b/packages/cli/tests/manifest-upload-zip.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import AdmZip from 'adm-zip'; + +/** + * Tests that uploadManifest uses zip import and preserves icons. + * Mocks at the apiFetch level to avoid intra-module mock limitations. + */ + +const MOCK_COLOR_ICON = Buffer.from('mock-color-icon-data'); +const MOCK_OUTLINE_ICON = Buffer.from('mock-outline-icon-data'); +const SERVER_MANIFEST = JSON.stringify({ + id: 'test-app-id', + version: '1.0.0', + manifestVersion: '1.16', + name: { short: 'Test' }, +}); + +function createMockPackage(): Buffer { + const zip = new AdmZip(); + zip.addFile('manifest.json', Buffer.from(SERVER_MANIFEST)); + zip.addFile('color.png', MOCK_COLOR_ICON); + zip.addFile('outline.png', MOCK_OUTLINE_ICON); + return zip.toBuffer(); +} + +let capturedImportBody: Uint8Array | null = null; +let mockDownloadFails = false; + +vi.mock('../src/utils/http.js', () => ({ + apiFetch: vi.fn(async (url: string, init?: RequestInit) => { + // Download app package endpoint + if (typeof url === 'string' && url.includes('/manifest') && init?.method !== 'POST') { + if (mockDownloadFails) { + return { ok: false, status: 404, statusText: 'Not Found', text: async () => 'Not found' }; + } + const zipBuffer = createMockPackage(); + const base64 = zipBuffer.toString('base64'); + return { + ok: true, + json: async () => base64, + }; + } + + // Import endpoint + if (typeof url === 'string' && url.includes('/import')) { + capturedImportBody = init?.body as Uint8Array; + return { + ok: true, + json: async () => ({ teamsAppId: 'test-app-id' }), + }; + } + + return { ok: true, json: async () => ({}) }; + }), +})); + +vi.mock('../src/project/paths.js', () => ({ + staticsDir: '/mock/statics', +})); + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + const mockedReadFileSync = vi.fn((...args: Parameters) => { + const filePath = args[0]; + if (typeof filePath === 'string' && filePath.startsWith('/mock/statics')) { + return Buffer.from('default-icon'); + } + return actual.readFileSync(...args); + }); + return { + ...actual, + default: { ...actual.default, readFileSync: mockedReadFileSync }, + readFileSync: mockedReadFileSync, + }; +}); + +const { uploadManifest } = await import('../src/apps/api.js'); + +describe('uploadManifest (zip import)', () => { + beforeEach(() => { + capturedImportBody = null; + mockDownloadFails = false; + }); + + it('calls the import endpoint with overwrite flag', async () => { + const { apiFetch } = await import('../src/utils/http.js'); + const newManifest = JSON.stringify({ id: 'test', version: '1.0.1' }); + await uploadManifest('token', 'test-app-id', newManifest); + + const importCall = vi.mocked(apiFetch).mock.calls.find( + (c) => typeof c[0] === 'string' && c[0].includes('/import') + ); + expect(importCall).toBeDefined(); + expect(importCall![0]).toContain('overwriteIfAppAlreadyExists=true'); + }); + + it('preserves existing icons from the downloaded package', async () => { + const newManifest = JSON.stringify({ id: 'test', version: '1.0.1' }); + await uploadManifest('token', 'test-app-id', newManifest); + + expect(capturedImportBody).not.toBeNull(); + const zip = new AdmZip(Buffer.from(capturedImportBody!)); + + const colorEntry = zip.getEntry('color.png'); + expect(colorEntry).toBeDefined(); + expect(colorEntry!.getData().toString()).toBe(MOCK_COLOR_ICON.toString()); + + const outlineEntry = zip.getEntry('outline.png'); + expect(outlineEntry).toBeDefined(); + expect(outlineEntry!.getData().toString()).toBe(MOCK_OUTLINE_ICON.toString()); + }); + + it('replaces manifest.json with the new content', async () => { + const newManifest = JSON.stringify({ id: 'updated', version: '2.0.0' }); + await uploadManifest('token', 'test-app-id', newManifest); + + const zip = new AdmZip(Buffer.from(capturedImportBody!)); + const manifestEntry = zip.getEntry('manifest.json'); + expect(manifestEntry).toBeDefined(); + expect(manifestEntry!.getData().toString()).toBe(newManifest); + }); + + it('has exactly one manifest.json entry', async () => { + const newManifest = JSON.stringify({ id: 'new-only', version: '1.0.0' }); + await uploadManifest('token', 'test-app-id', newManifest); + + const zip = new AdmZip(Buffer.from(capturedImportBody!)); + const entries = zip.getEntries().filter((e) => e.entryName === 'manifest.json'); + expect(entries).toHaveLength(1); + }); + + it('creates default zip when no existing package exists', async () => { + mockDownloadFails = true; + const newManifest = JSON.stringify({ id: 'first-upload', version: '1.0.0' }); + await uploadManifest('token', 'test-app-id', newManifest); + + expect(capturedImportBody).not.toBeNull(); + const zip = new AdmZip(Buffer.from(capturedImportBody!)); + expect(zip.getEntry('manifest.json')).toBeDefined(); + expect(zip.getEntry('color.png')).toBeDefined(); + expect(zip.getEntry('outline.png')).toBeDefined(); + + // Default icons, not the mock package icons + expect(zip.getEntry('color.png')!.getData().toString()).toBe('default-icon'); + }); +}); diff --git a/packages/cli/tests/version.test.ts b/packages/cli/tests/version.test.ts new file mode 100644 index 000000000..ff7554436 --- /dev/null +++ b/packages/cli/tests/version.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest'; +import { bumpPatchVersion, compareVersions } from '../src/utils/version.js'; + +describe('bumpPatchVersion', () => { + it('bumps patch segment of 3-part version', () => { + expect(bumpPatchVersion('1.0.0')).toBe('1.0.1'); + }); + + it('bumps patch segment of 2-part version', () => { + expect(bumpPatchVersion('1.0')).toBe('1.1'); + }); + + it('handles large numbers', () => { + expect(bumpPatchVersion('2.3.99')).toBe('2.3.100'); + }); + + it('returns null for single segment', () => { + expect(bumpPatchVersion('1')).toBeNull(); + }); + + it('returns null for 4+ segments', () => { + expect(bumpPatchVersion('1.2.3.4')).toBeNull(); + }); + + it('returns null for non-numeric last segment', () => { + expect(bumpPatchVersion('1.0.beta')).toBeNull(); + }); + + it('returns null for empty string', () => { + expect(bumpPatchVersion('')).toBeNull(); + }); +}); + +describe('compareVersions', () => { + it('returns 0 for equal versions', () => { + expect(compareVersions('1.0.0', '1.0.0')).toBe(0); + }); + + it('returns 1 when first is greater (patch)', () => { + expect(compareVersions('1.0.1', '1.0.0')).toBe(1); + }); + + it('returns -1 when first is less (patch)', () => { + expect(compareVersions('1.0.0', '1.0.1')).toBe(-1); + }); + + it('returns 1 when first is greater (minor)', () => { + expect(compareVersions('1.1.0', '1.0.0')).toBe(1); + }); + + it('returns 1 when first is greater (major)', () => { + expect(compareVersions('2.0.0', '1.0.0')).toBe(1); + }); + + it('treats missing segments as 0', () => { + expect(compareVersions('1.0', '1.0.0')).toBe(0); + }); + + it('compares different-length versions correctly', () => { + expect(compareVersions('1.0', '1.0.1')).toBe(-1); + }); + + it('returns null for non-numeric version a', () => { + expect(compareVersions('abc', '1.0.0')).toBeNull(); + }); + + it('returns null for non-numeric version b', () => { + expect(compareVersions('1.0.0', 'x.y.z')).toBeNull(); + }); + + it('returns null for empty segments', () => { + expect(compareVersions('1..0', '1.0.0')).toBeNull(); + }); + + it('returns null for trailing dot', () => { + expect(compareVersions('1.', '1.0')).toBeNull(); + }); +}); diff --git a/packages/cli/tests/web-app-info-update.test.ts b/packages/cli/tests/web-app-info-update.test.ts new file mode 100644 index 000000000..7d29d6a52 --- /dev/null +++ b/packages/cli/tests/web-app-info-update.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; +import { execSync } from 'node:child_process'; + +const CLI = 'node dist/index.js'; + +interface JsonErrorResponse { + ok: false; + error: { + code: string; + message?: string; + suggestion?: string; + }; +} + +function run(command: string): { stdout: string; exitCode: number } { + try { + const stdout = execSync(command, { encoding: 'utf-8', stdio: 'pipe' }); + return { stdout, exitCode: 0 }; + } catch (error: unknown) { + const execError = error as { stdout?: string; status?: number }; + return { + stdout: execError.stdout ?? '', + exitCode: execError.status ?? 1, + }; + } +} + +function runJson(command: string): { data: JsonErrorResponse; exitCode: number } { + const { stdout, exitCode } = run(command); + return { data: JSON.parse(stdout) as JsonErrorResponse, exitCode }; +} + +describe('app update --web-app-info-resource validation (offline)', () => { + it('rejects resource URI over 100 characters', () => { + const longUri = 'api://' + 'a'.repeat(100); + const { data, exitCode } = runJson( + `${CLI} app update some-app-id --web-app-info-resource "${longUri}" --json` + ); + expect(exitCode).toBe(1); + expect(data.error.code).toBe('VALIDATION_FORMAT'); + expect(data.error.message).toMatch(/100 characters/); + }); + + it('rejects resource URI without api:// prefix', () => { + const { data, exitCode } = runJson( + `${CLI} app update some-app-id --web-app-info-resource "https://example.com" --json` + ); + expect(exitCode).toBe(1); + expect(data.error.code).toBe('VALIDATION_FORMAT'); + expect(data.error.message).toMatch(/api:\/\//); + }); + + it('accepts resource URI within limit (fails at auth, not validation)', () => { + const uri = 'api://botid-00000000-0000-0000-0000-000000000001'; + const { data, exitCode } = runJson( + `${CLI} app update some-app-id --web-app-info-resource "${uri}" --json` + ); + // Should fail at auth (not logged in), not at validation + expect(exitCode).toBe(1); + expect(data.error.code).not.toBe('VALIDATION_FORMAT'); + }); + + it('accepts --web-app-info-id as a mutation flag for --json mode', () => { + const { data, exitCode } = runJson( + `${CLI} app update some-app-id --web-app-info-id "00000000-0000-0000-0000-000000000001" --json` + ); + // Should fail at auth, not at "no mutation flags" validation + expect(exitCode).toBe(1); + expect(data.error.code).not.toBe('VALIDATION_MISSING'); + }); +}); diff --git a/teams.md/docs/cli/commands/app/manifest-upload.md b/teams.md/docs/cli/commands/app/manifest-upload.md index f65d075c4..4a8006ffc 100644 --- a/teams.md/docs/cli/commands/app/manifest-upload.md +++ b/teams.md/docs/cli/commands/app/manifest-upload.md @@ -25,5 +25,30 @@ teams app manifest upload [appId] [options] | Flag | Description | |------|-------------| +| `--no-bump-version` | [OPTIONAL] Disable automatic version bumping | | `--json` | [OPTIONAL] Output as JSON | + +## Details + +Uploads a local `manifest.json` to update an existing Teams app. The manifest is packaged into a zip (preserving existing icons) and imported via the TDP import endpoint, so all manifest fields are supported — including `webApplicationInfo.resource`. + +### Automatic Version Bumping + +When the manifest version matches the server's current version but the content has changed, the CLI automatically bumps the patch version (e.g., `1.0.0` → `1.0.1`). This only affects the uploaded copy — your local file is not modified. + +Use `--no-bump-version` to disable this behavior. + +### Examples + +Upload a manifest: + +```bash +teams app manifest upload manifest.json +``` + +Upload without auto version bump: + +```bash +teams app manifest upload manifest.json --no-bump-version +``` diff --git a/teams.md/docs/cli/commands/app/update.md b/teams.md/docs/cli/commands/app/update.md index b7112ed8a..202b5e7d0 100644 --- a/teams.md/docs/cli/commands/app/update.md +++ b/teams.md/docs/cli/commands/app/update.md @@ -29,10 +29,16 @@ teams app update [appId] [options] | `--long-name ` | [OPTIONAL] Set the app long name (max 100 chars) | | `--short-description ` | [OPTIONAL] Set the short description (max 80 chars) | | `--long-description ` | [OPTIONAL] Set the long description (max 4000 chars) | +| `--version ` | [OPTIONAL] Set the app version | | `--developer ` | [OPTIONAL] Set the developer name | | `--website ` | [OPTIONAL] Set the website URL (HTTPS required) | | `--privacy-url ` | [OPTIONAL] Set the privacy policy URL (HTTPS required) | | `--terms-url ` | [OPTIONAL] Set the terms of use URL (HTTPS required) | +| `--color-icon ` | [OPTIONAL] Set the color icon (192x192 PNG) | +| `--outline-icon ` | [OPTIONAL] Set the outline icon (32x32 PNG) | +| `--web-app-info-id ` | [OPTIONAL] Set the webApplicationInfo client ID | +| `--web-app-info-resource ` | [OPTIONAL] Set the webApplicationInfo resource URI (max 100 chars) | +| `--json` | [OPTIONAL] Output as JSON | ## Details @@ -62,6 +68,14 @@ Update multiple properties at once: teams app update --name "New Name" --version "2.0.0" --developer "My Team" ``` +### webApplicationInfo (SSO) + +Set the `webApplicationInfo` fields used for SSO and app identity: + +```bash +teams app update --web-app-info-id --web-app-info-resource "api://botid-" +``` + ### Portal Equivalent Apps → select app → Basic information in the [Teams Developer Portal](https://dev.teams.microsoft.com).