@quadient/skillshare 1.6.1 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,8 +19,40 @@ bun run index.ts install 12 0.1.0 --target ./installed
19
19
  bun run index.ts install --skill-version-id 42 --target ./installed
20
20
  ```
21
21
 
22
+ ## Teams MCP
23
+
24
+ Starting with 1.7.0, `skillshare teams login` opens a dedicated local browser profile,
25
+ connects your own Teams account to Skillshare and verifies the connection. Sign in with
26
+ the same Microsoft account as Skillshare. The server refreshes captured tokens in the
27
+ background. `teams watch` renews an expired Microsoft grant through local browser SSO.
28
+ Keep that process running for automatic recovery beyond the SPA refresh token lifetime;
29
+ another interactive CLI login is needed only when SSO cannot complete, for example MFA.
30
+
31
+ ```sh
32
+ skillshare login
33
+ skillshare teams login
34
+ skillshare teams status
35
+ skillshare teams test
36
+ skillshare teams watch
37
+ skillshare teams access fullAccess
38
+ skillshare teams upload ./report.pdf
39
+ skillshare teams download '<file-webUrl-or-download-link>' --output ./report.pdf
40
+ skillshare teams access readOnly
41
+ skillshare teams logout
42
+ ```
43
+
44
+ Use `teams login local` for localhost or `--server <https-origin>` for another host.
45
+ `login` accepts `--server` too. Browser options: `--browser chrome|msedge|chromium`,
46
+ `--headless`, and `--browser-profile <dedicated-profile-directory>`.
47
+ Chrome is the default on macOS/Linux, Edge on Windows; bundled Chromium needs
48
+ `npx playwright install chromium` first. See [Teams MCP](../docs/teams-mcp.md) for
49
+ permissions, Microsoft session lifetime and file-tool differences in the hosted server.
50
+
22
51
  ## Behavior
23
52
 
53
+ - Starting with 1.6.2, `login`, `git configure` and API commands without a stored session default to `https://skillshare.quadientcloudpreprod.eu`. `login local` and `git configure local` still select `http://localhost:5050`.
54
+ - Existing sessions keep their saved server. After preprod cutover, run `skillshare login` again to replace a UAT session with a preprod session; stored tokens are not reused across hosts.
55
+ - Uploaded/installed records retain their registry URL. Legacy records without a URL still belong to UAT and are not silently reassigned to preprod.
24
56
  - Upload and publish commands show progress while scanning and reading local files, then keep a timed upload status visible until the registry call finishes.
25
57
  - `update` reads the centralized install registry and refreshes recorded installs to the latest published registry versions.
26
58
  - `update` can be narrowed to one skill or one target directory, and `--dry-run` prints planned changes without downloading or overwriting files.
@@ -36,7 +68,7 @@ flowchart TD
36
68
  B -- Yes --> D{Which command?}
37
69
 
38
70
  D -->|login local| E[login LOCAL_URL]
39
- D -->|login| F[login PROD_URL]
71
+ D -->|login| F[login DEFAULT_URL]
40
72
  D -->|logout| G[logoutSession]
41
73
  D -->|whoami list show upload install update| H[createClient using active baseUrl]
42
74
 
@@ -148,7 +180,7 @@ export SKILLSHARE_TOKEN="<personal-access-token>"
148
180
  npx @quadient/skillshare whoami
149
181
  ```
150
182
 
151
- Register the production or local SkillShare host as a generic OAuth provider in Git Credential
183
+ Register the default preprod or local SkillShare host as a generic OAuth provider in Git Credential
152
184
  Manager 2.0.931 or newer:
153
185
 
154
186
  ```bash
@@ -179,7 +211,7 @@ npx @quadient/skillshare pages show release-notes
179
211
  ```
180
212
 
181
213
  The page is then available at
182
- `https://skillshare.inspireclouduat.net/p/release-notes/` after SSO sign-in.
214
+ `https://skillshare.quadientcloudpreprod.eu/p/release-notes/` after SSO sign-in.
183
215
 
184
216
  ## Package Distribution
185
217
 
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { assertCredentialDestination, runTeamsCommand } from './teams.js';
1
2
  import { preparePageFiles } from './pages.js';
2
3
  import { runPageDev } from './pages-dev.js';
3
4
  import { createServer } from 'node:http';
@@ -8,7 +9,9 @@ import { mkdir, open, readFile, readdir, rm, stat, writeFile } from 'node:fs/pro
8
9
  import { basename, dirname, join, relative, resolve } from 'node:path';
9
10
  import { homedir } from 'node:os';
10
11
  import { cancel, confirm, intro, isCancel, multiselect, outro, progress, select, spinner, text } from '@clack/prompts';
11
- const PROD_URL = 'https://skillshare.inspireclouduat.net';
12
+ const DEFAULT_URL = 'https://skillshare.quadientcloudpreprod.eu';
13
+ // Records without a registry URL were created against UAT before the preprod migration.
14
+ const LEGACY_UAT_URL = 'https://skillshare.inspireclouduat.net';
12
15
  const LOCAL_URL = 'http://localhost:5050';
13
16
  const APP_DIR = getAppDir();
14
17
  const SESSION_PATH = join(APP_DIR, 'session.json');
@@ -86,11 +89,23 @@ Commands:
86
89
  pages dev [directory] [--slug <slug>] [--port <port>]
87
90
  [--build <command> --watch-dir <sources>] [--celld <executable>]
88
91
  [--user-id <id>] [--user-name <name>] [--user-email <email>]
92
+ teams login [local] [--browser chrome|msedge|chromium] [--headless]
93
+ teams watch [local] [--browser chrome|msedge|chromium]
94
+ teams logout
95
+ teams status
96
+ teams test
97
+ teams access readOnly|fullAccess
98
+ teams upload <file>
99
+ teams download <url> --output <new-file>
89
100
  git configure [local]
90
101
  git unconfigure [local]
91
102
 
92
103
  Options:
93
104
  --token <token> Bearer token for API requests (overrides SKILLSHARE_TOKEN)
105
+ --server <url> Skillshare origin for login and Teams commands
106
+
107
+ Default server: ${DEFAULT_URL}
108
+ A stored session keeps its server; run "skillshare login" to switch to the default.
94
109
  `;
95
110
  async function main() {
96
111
  const args = process.argv.slice(2);
@@ -110,16 +125,26 @@ async function main() {
110
125
  }
111
126
  switch (command) {
112
127
  case 'login': {
113
- await login(options.positionals[0] === 'local' ? LOCAL_URL : PROD_URL);
128
+ const baseUrl = options.flags.server ?? (options.positionals[0] === 'local' ? LOCAL_URL : DEFAULT_URL);
129
+ assertCredentialDestination(baseUrl);
130
+ await login(baseUrl);
114
131
  return;
115
132
  }
116
133
  case 'logout': {
117
134
  await logoutSession();
118
135
  return;
119
136
  }
137
+ case 'teams': {
138
+ const baseUrl = options.flags.server ?? (options.positionals[1] === 'local' ? LOCAL_URL : await resolveActiveBaseUrl());
139
+ assertCredentialDestination(baseUrl);
140
+ if (!options.token && !await loadSession(baseUrl))
141
+ await login(baseUrl);
142
+ await runTeamsCommand(await createClient(baseUrl, options.token), options.positionals, options.flags, APP_DIR);
143
+ return;
144
+ }
120
145
  case 'git': {
121
146
  const action = options.positionals[0];
122
- const baseUrl = options.positionals[1] === 'local' ? LOCAL_URL : PROD_URL;
147
+ const baseUrl = options.positionals[1] === 'local' ? LOCAL_URL : DEFAULT_URL;
123
148
  if (action === 'configure') {
124
149
  await configureGitCredentialManager(baseUrl);
125
150
  return;
@@ -1410,7 +1435,7 @@ function formatVersion(version) {
1410
1435
  return version.join('.');
1411
1436
  }
1412
1437
  async function parseOptions(args) {
1413
- const booleanFlags = new Set(['dry-run']);
1438
+ const booleanFlags = new Set(['dry-run', 'headless']);
1414
1439
  const flags = {};
1415
1440
  const positionals = [];
1416
1441
  for (let index = 0; index < args.length; index += 1) {
@@ -1421,6 +1446,10 @@ async function parseOptions(args) {
1421
1446
  if (current.startsWith('--')) {
1422
1447
  const key = current.slice(2);
1423
1448
  const value = args[index + 1];
1449
+ if (key === 'headless' && value !== 'true' && value !== 'false') {
1450
+ flags[key] = 'true';
1451
+ continue;
1452
+ }
1424
1453
  if (!value || value.startsWith('--')) {
1425
1454
  if (booleanFlags.has(key)) {
1426
1455
  flags[key] = 'true';
@@ -1455,7 +1484,7 @@ async function resolveActiveBaseUrl() {
1455
1484
  if (session?.baseUrl) {
1456
1485
  return normalizeBaseUrl(session.baseUrl);
1457
1486
  }
1458
- return PROD_URL;
1487
+ return DEFAULT_URL;
1459
1488
  }
1460
1489
  async function createClient(baseUrl, explicitToken) {
1461
1490
  const resolvedBaseUrl = normalizeBaseUrl(baseUrl);
@@ -1533,6 +1562,10 @@ async function createClient(baseUrl, explicitToken) {
1533
1562
  });
1534
1563
  return parseJson(response);
1535
1564
  },
1565
+ async request(path, init) {
1566
+ // Streaming uploads cannot be replayed on an expired Skillshare access token.
1567
+ return sendAuthorized(path, { ...init, redirect: 'error' }, typeof init.body === 'string' || !init.body);
1568
+ },
1536
1569
  async rawGet(path) {
1537
1570
  const response = await sendAuthorized(path);
1538
1571
  if (!response.ok) {
@@ -1823,7 +1856,7 @@ async function readUploadedSkillRecords() {
1823
1856
  const normalizedSkillId = Number.isInteger(skillId) && skillId > 0 ? skillId : undefined;
1824
1857
  return [{
1825
1858
  directory: typeof entry.directory === 'string' ? resolve(entry.directory) : '',
1826
- baseUrl: typeof entry.baseUrl === 'string' ? normalizeBaseUrl(entry.baseUrl) : PROD_URL,
1859
+ baseUrl: typeof entry.baseUrl === 'string' ? normalizeBaseUrl(entry.baseUrl) : LEGACY_UAT_URL,
1827
1860
  updatedAt: typeof entry.updatedAt === 'string' ? entry.updatedAt : '',
1828
1861
  skillId: normalizedSkillId,
1829
1862
  skillName: typeof entry.skillName === 'string' ? entry.skillName : '',
@@ -1844,7 +1877,7 @@ async function readInstalledSkillRecords() {
1844
1877
  }
1845
1878
  return [{
1846
1879
  targetRoot: typeof entry.targetRoot === 'string' ? resolve(entry.targetRoot) : '',
1847
- baseUrl: typeof entry.baseUrl === 'string' ? normalizeBaseUrl(entry.baseUrl) : PROD_URL,
1880
+ baseUrl: typeof entry.baseUrl === 'string' ? normalizeBaseUrl(entry.baseUrl) : LEGACY_UAT_URL,
1848
1881
  updatedAt: typeof entry.updatedAt === 'string' ? entry.updatedAt : '',
1849
1882
  skillId,
1850
1883
  skillName: typeof entry.skillName === 'string' ? entry.skillName : '',
@@ -0,0 +1,304 @@
1
+ import { mkdir } from 'node:fs/promises';
2
+ import { setTimeout as delay } from 'node:timers/promises';
3
+ const origins = new Set(['https://teams.microsoft.com', 'https://teams.cloud.microsoft']);
4
+ const uuid = /^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i;
5
+ export function decodeTeamsJwt(value) {
6
+ try {
7
+ const part = value.split('.')[1];
8
+ if (!part)
9
+ return null;
10
+ return JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ }
16
+ /** Select only credentials belonging to the Skillshare account. No full browser state leaves this machine. */
17
+ export function extractTeamsSession(state, expectedObjectId, now = Date.now(), allowExpired = false) {
18
+ if (!uuid.test(expectedObjectId))
19
+ throw new Error('Skillshare must be signed in with a Microsoft account before connecting Teams.');
20
+ const stores = state.origins.filter(origin => origins.has(origin.origin));
21
+ const entries = stores.flatMap(origin => origin.localStorage.flatMap(item => {
22
+ try {
23
+ const entry = JSON.parse(item.value);
24
+ return entry && typeof entry === 'object' && !Array.isArray(entry)
25
+ ? [{ key: item.name, entry: entry, origin: origin.origin }] : [];
26
+ }
27
+ catch {
28
+ return [];
29
+ }
30
+ }));
31
+ const tokens = {};
32
+ let profile = null;
33
+ let clientId = '';
34
+ let accountId = '';
35
+ let spacesOrigin = '';
36
+ let substrateScope = 'https://substrate.office.com/.default offline_access';
37
+ for (const { entry, origin } of entries) {
38
+ if (entry.credentialType !== 'AccessToken' || typeof entry.secret !== 'string')
39
+ continue;
40
+ const claims = decodeTeamsJwt(entry.secret);
41
+ if (claims?.oid?.toLowerCase() !== expectedObjectId.toLowerCase() || !claims.tid || typeof claims.exp !== 'number' || !allowExpired && claims.exp * 1000 <= now + 30_000)
42
+ continue;
43
+ const target = typeof entry.target === 'string' ? entry.target : '';
44
+ const resource = target.includes('SubstrateSearch') ? 'substrate' :
45
+ target.includes('api.spaces.skype.com') ? 'spaces' : target.includes('chatsvcagg.teams.microsoft.com') ? 'csa' :
46
+ target.includes('graph.microsoft.com') || claims.aud === '00000003-0000-0000-c000-000000000000' ? 'graph' : undefined;
47
+ if (!resource)
48
+ continue;
49
+ if (profile?.tid && profile.tid !== claims.tid)
50
+ continue;
51
+ if (!tokens[resource] || new Date(tokens[resource].expiresAt).getTime() < claims.exp * 1000) {
52
+ if (resource === 'substrate')
53
+ substrateScope = target.includes('outlook.office.com/search')
54
+ ? 'https://outlook.office.com/search/.default offline_access' : 'https://substrate.office.com/.default offline_access';
55
+ if (resource === 'spaces') {
56
+ spacesOrigin = origin;
57
+ if (typeof entry.clientId === 'string')
58
+ clientId = entry.clientId;
59
+ if (typeof entry.homeAccountId === 'string')
60
+ accountId = entry.homeAccountId;
61
+ }
62
+ tokens[resource] = { value: entry.secret, expiresAt: new Date(claims.exp * 1000).toISOString() };
63
+ profile ??= claims;
64
+ }
65
+ }
66
+ if (!profile?.tid || !allowExpired && !tokens.substrate || !tokens.spaces || !uuid.test(clientId))
67
+ return null;
68
+ const accountStores = new Set(entries.filter(({ entry }) => entry.homeAccountId === accountId).map(({ origin }) => origin));
69
+ const configs = entries.filter(({ key, origin }) => key.includes('DISCOVER-REGION-GTM') && accountStores.has(origin));
70
+ const discovery = configs.find(({ entry }) => typeof entry.item?.chatServiceAfd === 'string');
71
+ const item = discovery?.entry.item;
72
+ if (!item || typeof item.chatServiceAfd !== 'string' || typeof item.middleTier !== 'string')
73
+ return null;
74
+ let origin;
75
+ try {
76
+ origin = new URL(item.chatServiceAfd).origin;
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ if (!origins.has(origin) || !/^\/api\/chatsvc\/[a-z]+$/.test(new URL(item.chatServiceAfd).pathname))
82
+ return null;
83
+ const csa = typeof item.chatSvcAggAfd === 'string' ? item.chatSvcAggAfd : item.chatServiceAfd.replace('/chatsvc/', '/csa/');
84
+ if (!validServiceUrl(item.middleTier, origin, 'mt') || !validServiceUrl(csa, origin, 'csa'))
85
+ return null;
86
+ const skype = state.cookies.filter(cookie => {
87
+ const claims = decodeTeamsJwt(cookie.value);
88
+ return cookie.name === 'skypetoken_asm' && claims?.skypeid?.replace(/^8:/, '').toLowerCase() === `orgid:${expectedObjectId.toLowerCase()}` &&
89
+ claims.tid === profile.tid && typeof claims.exp === 'number' &&
90
+ (/^\.?([a-z0-9-]+\.)?(asm\.skype\.com|asyncgw\.teams\.microsoft\.com)$/.test(cookie.domain) ||
91
+ /^\.?(teams\.microsoft\.com|teams\.cloud\.microsoft)$/.test(cookie.domain)) && (allowExpired || Math.min(claims.exp * 1000, cookie.expires * 1000) > now + 30_000);
92
+ })
93
+ .sort((a, b) => b.expires - a.expires)[0];
94
+ if (!skype && !allowExpired)
95
+ return null;
96
+ if (skype)
97
+ tokens.skype = { value: skype.value, expiresAt: new Date(decodeTeamsJwt(skype.value).exp * 1000).toISOString() };
98
+ const refresh = entries.find(({ entry, origin }) => origin === spacesOrigin && entry.credentialType === 'RefreshToken' && entry.homeAccountId === accountId &&
99
+ entry.clientId === clientId && typeof entry.secret === 'string')?.entry.secret;
100
+ if (typeof refresh !== 'string')
101
+ return null;
102
+ return {
103
+ tenantId: profile.tid, objectId: expectedObjectId.toLowerCase(), displayName: profile.name ?? expectedObjectId,
104
+ email: profile.upn ?? profile.preferred_username ?? '', clientId, substrateScope, refreshToken: typeof refresh === 'string' ? refresh : undefined,
105
+ teamsOrigin: origin, chatServiceUrl: item.chatServiceAfd, middleTierUrl: item.middleTier, csaServiceUrl: csa, tokens,
106
+ };
107
+ }
108
+ function validServiceUrl(value, origin, service) {
109
+ try {
110
+ const url = new URL(value);
111
+ return url.origin === origin && !url.username && !url.password && !url.search && !url.hash &&
112
+ (service === 'mt' ? /^\/api\/mt\/(part\/)?[a-z]+(-\d+)?$/ : /^\/api\/csa\/[a-z]+$/).test(url.pathname);
113
+ }
114
+ catch {
115
+ return false;
116
+ }
117
+ }
118
+ export async function captureTeamsLogin(options) {
119
+ options.signal?.throwIfAborted();
120
+ const { chromium } = await import('playwright');
121
+ const channel = options.browser ?? (process.platform === 'win32' ? 'msedge' : 'chrome');
122
+ if (!['chrome', 'msedge', 'chromium'].includes(channel))
123
+ throw new Error('--browser must be chrome, msedge or chromium.');
124
+ await mkdir(options.profileDirectory, { recursive: true, mode: 0o700 });
125
+ const controller = new AbortController();
126
+ const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
127
+ const timeout = setTimeout(() => controller.abort(new Error('Teams login timed out. Finish sign-in with the same account as Skillshare, then run skillshare teams login again.')), options.timeoutMs ?? 10 * 60_000);
128
+ let context;
129
+ try {
130
+ context = await chromium.launchPersistentContext(options.profileDirectory, {
131
+ channel: channel === 'chromium' ? undefined : channel,
132
+ headless: options.headless ?? false,
133
+ viewport: null,
134
+ timeout: Math.min(options.timeoutMs ?? 60_000, 60_000),
135
+ });
136
+ }
137
+ catch {
138
+ clearTimeout(timeout);
139
+ signal.throwIfAborted();
140
+ throw new Error(`Could not launch ${channel}. Install that browser or use --browser chromium after running npx playwright install chromium. Close another Teams login using this profile and retry.`);
141
+ }
142
+ const close = () => { void context.close().catch(() => { }); };
143
+ const interrupt = () => controller.abort(new Error('Teams login cancelled.'));
144
+ process.once('SIGINT', interrupt);
145
+ process.once('SIGTERM', interrupt);
146
+ signal.addEventListener('abort', close, { once: true });
147
+ try {
148
+ signal.throwIfAborted();
149
+ const page = context.pages()[0] ?? await abortable(context.newPage(), signal);
150
+ options.onProgress?.('Sign in to Teams with the same Microsoft account as Skillshare. Existing login cookies are preserved.');
151
+ await abortable(page.goto('https://teams.microsoft.com/v2/', { waitUntil: 'domcontentloaded', timeout: 60_000 }), signal);
152
+ let lastProgress = Date.now();
153
+ let lastRefresh = 0;
154
+ let resetCache = false;
155
+ while (true) {
156
+ signal.throwIfAborted();
157
+ // Read the current Teams tab only. storageState visits every cached origin and can hang
158
+ // on Teams service-worker navigation; browser cookies remain local in this snapshot.
159
+ let local;
160
+ try {
161
+ local = await abortable(page.evaluate(() => ({ origin: location.origin,
162
+ localStorage: Object.entries(localStorage).map(([name, value]) => ({ name, value })) })), signal);
163
+ }
164
+ catch (error) {
165
+ signal.throwIfAborted();
166
+ if (error instanceof Error && /Execution context was destroyed|Cannot find context|Frame was detached/.test(error.message)) {
167
+ await delay(250, undefined, { signal });
168
+ continue;
169
+ }
170
+ throw error;
171
+ }
172
+ const state = { origins: [local], cookies: await abortable(context.cookies(), signal) };
173
+ const session = extractTeamsSession(state, options.objectId);
174
+ if (session && !options.recover)
175
+ return session;
176
+ if (origins.has(new URL(page.url()).origin) && Date.now() - lastRefresh > 30_000) {
177
+ const cached = session ?? extractTeamsSession(state, options.objectId, Date.now(), true);
178
+ if (cached?.refreshToken) {
179
+ lastRefresh = Date.now();
180
+ try {
181
+ return await abortable(refreshCapturedSession(cached, fetch, options.recover ?? false, signal), signal);
182
+ }
183
+ catch {
184
+ signal.throwIfAborted();
185
+ if (options.recover && !resetCache) {
186
+ resetCache = true;
187
+ // Expired grants can coexist with valid access tokens. Remove only this account's MSAL
188
+ // credentials so Teams starts a new authorization flow, preserving SSO cookies and account hints.
189
+ options.onProgress?.('The cached grant expired. Renewing Teams sign-in using browser SSO cookies.');
190
+ await abortable(resetTeamsGrant(page, options.objectId, cached.clientId), signal);
191
+ await abortable(page.reload({ waitUntil: 'domcontentloaded', timeout: 60_000 }), signal);
192
+ lastRefresh = 0;
193
+ }
194
+ }
195
+ }
196
+ }
197
+ if (Date.now() - lastProgress > 30_000) {
198
+ options.onProgress?.('Waiting for Teams sign-in and API tokens. Finish sign-in in the browser; open Teams search if needed.');
199
+ lastProgress = Date.now();
200
+ }
201
+ await delay(1500, undefined, { signal });
202
+ }
203
+ }
204
+ catch (error) {
205
+ signal.throwIfAborted();
206
+ throw error;
207
+ }
208
+ finally {
209
+ clearTimeout(timeout);
210
+ process.removeListener('SIGINT', interrupt);
211
+ process.removeListener('SIGTERM', interrupt);
212
+ signal.removeEventListener('abort', close);
213
+ await Promise.race([context.close().catch(() => { }), delay(3000, undefined, { ref: false })]);
214
+ }
215
+ }
216
+ function abortable(operation, signal) {
217
+ return new Promise((resolve, reject) => {
218
+ const abort = () => reject(signal.reason);
219
+ signal.addEventListener('abort', abort, { once: true });
220
+ if (signal.aborted)
221
+ abort();
222
+ operation.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
223
+ });
224
+ }
225
+ /** The Teams web SPA may not request a search token until search is opened. Reuse its MSAL grant instead. */
226
+ export async function refreshCapturedSession(session, request = fetch, force = false, signal) {
227
+ if (!session.refreshToken)
228
+ throw new Error('A Teams refresh token is required to renew this session.');
229
+ const scopes = {
230
+ substrate: session.substrateScope ?? 'https://substrate.office.com/.default offline_access',
231
+ spaces: 'https://api.spaces.skype.com/.default offline_access',
232
+ csa: 'https://chatsvcagg.teams.microsoft.com/.default offline_access',
233
+ graph: 'https://graph.microsoft.com/.default offline_access',
234
+ };
235
+ for (const resource of ['substrate', 'spaces', 'csa', ...(session.tokens.graph ? ['graph'] : [])]) {
236
+ if (!force && session.tokens[resource] && new Date(session.tokens[resource].expiresAt).getTime() > Date.now() + 120_000)
237
+ continue;
238
+ const response = await request(`https://login.microsoftonline.com/${session.tenantId}/oauth2/v2.0/token`, {
239
+ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: session.teamsOrigin },
240
+ body: new URLSearchParams({ client_id: session.clientId, grant_type: 'refresh_token', refresh_token: session.refreshToken, scope: scopes[resource] }),
241
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000), redirect: 'error',
242
+ });
243
+ if (!response.ok) {
244
+ if (resource === 'graph') {
245
+ delete session.tokens.graph;
246
+ continue;
247
+ }
248
+ throw new Error('Teams needs interactive sign-in to renew this session.');
249
+ }
250
+ const data = await response.json();
251
+ const claims = decodeTeamsJwt(data.access_token);
252
+ if (claims?.oid?.toLowerCase() !== session.objectId.toLowerCase() || claims.tid !== session.tenantId || !claims.exp)
253
+ throw new Error('The renewed Teams token belongs to a different account.');
254
+ session.tokens[resource] = { value: data.access_token, expiresAt: new Date(claims.exp * 1000).toISOString() };
255
+ session.refreshToken = data.refresh_token ?? session.refreshToken;
256
+ }
257
+ if (!session.tokens.skype || new Date(session.tokens.skype.expiresAt).getTime() <= Date.now() + 120_000) {
258
+ const response = await request('https://authsvc.teams.microsoft.com/v1.0/authz', {
259
+ method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.tokens.spaces.value}` },
260
+ body: '{}', signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000), redirect: 'error',
261
+ });
262
+ if (!response.ok)
263
+ throw new Error('Teams could not renew the Skype token.');
264
+ const data = await response.json();
265
+ if (!data.tokens?.skypeToken)
266
+ throw new Error('Teams returned no Skype token.');
267
+ const claims = decodeTeamsJwt(data.tokens.skypeToken);
268
+ if (claims?.skypeid?.replace(/^8:/, '').toLowerCase() !== `orgid:${session.objectId.toLowerCase()}` || claims.tid !== session.tenantId || !claims.exp)
269
+ throw new Error('The renewed Skype token belongs to a different account.');
270
+ session.tokens.skype = { value: data.tokens.skypeToken, expiresAt: new Date(claims.exp * 1000).toISOString() };
271
+ }
272
+ return session;
273
+ }
274
+ async function resetTeamsGrant(page, objectId, clientId) {
275
+ if (!origins.has(new URL(page.url()).origin))
276
+ return;
277
+ await page.evaluate(({ objectId, clientId }) => {
278
+ const entries = Object.entries(localStorage).flatMap(([key, value]) => {
279
+ try {
280
+ const entry = JSON.parse(value);
281
+ return entry && typeof entry === 'object' && entry.clientId === clientId ? [{ key, entry }] : [];
282
+ }
283
+ catch {
284
+ return [];
285
+ }
286
+ });
287
+ const access = entries.filter(({ entry }) => {
288
+ if (entry.credentialType !== 'AccessToken' || typeof entry.secret !== 'string')
289
+ return false;
290
+ try {
291
+ const claims = JSON.parse(atob(entry.secret.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
292
+ return claims.oid?.toLowerCase() === objectId.toLowerCase();
293
+ }
294
+ catch {
295
+ return false;
296
+ }
297
+ });
298
+ const accounts = new Set(access.map(({ entry }) => entry.homeAccountId));
299
+ for (const { key, entry } of entries) {
300
+ if (access.some(item => item.key === key) || entry.credentialType === 'RefreshToken' && accounts.has(entry.homeAccountId))
301
+ localStorage.removeItem(key);
302
+ }
303
+ }, { objectId, clientId });
304
+ }
@@ -0,0 +1,45 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
2
+ /** The server renews access tokens; a local browser supplies a new SSO grant when that grant expires. */
3
+ export async function watchTeamsConnection(options) {
4
+ const now = options.now ?? Date.now;
5
+ const wait = options.wait ?? (async (ms, signal) => { await delay(ms, undefined, { signal }); });
6
+ let retryAt = 0;
7
+ let warned = false;
8
+ while (!options.signal.aborted) {
9
+ try {
10
+ const status = await options.readStatus();
11
+ if (!status.connectionId) {
12
+ options.onProgress?.('Teams is disconnected. Watch stopped; run skillshare teams login before starting it again.');
13
+ return;
14
+ }
15
+ if (status.requiresReconnect && now() >= retryAt) {
16
+ options.onProgress?.('Microsoft needs a new Teams session. Renewing it through local browser SSO.');
17
+ // Conditional import prevents a disconnect or another login during capture from being overwritten.
18
+ await options.reconnect(status.connectionId);
19
+ options.onProgress?.('Teams SSO session renewed and saved to Skillshare.');
20
+ retryAt = 0;
21
+ warned = false;
22
+ }
23
+ else if (status.configured) {
24
+ retryAt = 0;
25
+ warned = false;
26
+ }
27
+ }
28
+ catch {
29
+ if (options.signal.aborted)
30
+ return;
31
+ retryAt = now() + 5 * 60_000;
32
+ if (!warned)
33
+ options.onProgress?.('Automatic Teams renewal is waiting for browser sign-in or server access. If MFA is required, run skillshare teams login in another terminal. Retrying in five minutes.');
34
+ warned = true;
35
+ }
36
+ try {
37
+ await wait(options.intervalMs ?? 60_000, options.signal);
38
+ }
39
+ catch (error) {
40
+ if (options.signal.aborted)
41
+ return;
42
+ throw error;
43
+ }
44
+ }
45
+ }
package/dist/teams.js ADDED
@@ -0,0 +1,142 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { createReadStream } from 'node:fs';
3
+ import { open, rm, stat } from 'node:fs/promises';
4
+ import { basename, join, resolve } from 'node:path';
5
+ import { Readable } from 'node:stream';
6
+ import { pipeline } from 'node:stream/promises';
7
+ import { captureTeamsLogin } from './teams-auth.js';
8
+ import { watchTeamsConnection } from './teams-watch.js';
9
+ const root = '/api/mcps/providers/teams';
10
+ async function expectOk(response) {
11
+ if (response.ok)
12
+ return;
13
+ let error = {};
14
+ try {
15
+ error = await response.json();
16
+ }
17
+ catch { }
18
+ throw new Error(error.error ?? `Skillshare Teams API returned HTTP ${response.status}.`);
19
+ }
20
+ export async function runTeamsCommand(client, args, flags, appDirectory) {
21
+ // Browser credentials may be uploaded only over HTTPS, with HTTP reserved for loopback development.
22
+ assertCredentialDestination(client.baseUrl);
23
+ const action = args[0];
24
+ if (action === 'login' || action === 'watch') {
25
+ const user = await client.get('/api/auth/me');
26
+ const key = createHash('sha256').update(client.baseUrl + '\0' + user.id).digest('hex').slice(0, 24);
27
+ const profileDirectory = flags['browser-profile'] ? resolve(flags['browser-profile']) : join(appDirectory, 'teams', key, 'browser-profile');
28
+ const abort = new AbortController();
29
+ async function connect(connectionId) {
30
+ const session = await captureTeamsLogin({ profileDirectory, objectId: user.id, browser: flags.browser,
31
+ headless: action === 'watch' ? flags.headless !== 'false' : flags.headless === 'true', recover: action === 'watch',
32
+ signal: abort.signal, timeoutMs: action === 'watch' ? 90_000 : undefined, onProgress: console.log });
33
+ const headers = { 'Content-Type': 'application/json' };
34
+ if (connectionId)
35
+ headers['If-Match'] = `"${connectionId}"`;
36
+ const response = await client.request(`${root}/session`, { method: 'PUT', headers, body: JSON.stringify(session), signal: abort.signal });
37
+ await expectOk(response);
38
+ await client.post(`${root}/test`);
39
+ }
40
+ if (action === 'login') {
41
+ await connect();
42
+ console.log(`Teams connected. Skillshare refreshes tokens automatically. Use skillshare teams watch for renewed browser SSO after session expiry.`);
43
+ console.log(`MCP endpoint: ${client.baseUrl}/teams/mcp`);
44
+ return;
45
+ }
46
+ const stop = () => abort.abort();
47
+ process.once('SIGINT', stop);
48
+ process.once('SIGTERM', stop);
49
+ try {
50
+ console.log('Watching Teams session. Server refreshes access tokens; this process renews expired grants through local SSO. Press Ctrl+C to stop.');
51
+ await watchTeamsConnection({ readStatus: () => client.get(`${root}/settings`), reconnect: connect,
52
+ signal: abort.signal, onProgress: console.log });
53
+ }
54
+ finally {
55
+ process.removeListener('SIGINT', stop);
56
+ process.removeListener('SIGTERM', stop);
57
+ }
58
+ return;
59
+ }
60
+ if (action === 'status' || action === 'test') {
61
+ const result = action === 'test' ? await client.post(`${root}/test`) : await client.get(`${root}/settings`);
62
+ console.log(JSON.stringify(result, null, 2));
63
+ return;
64
+ }
65
+ if (action === 'logout') {
66
+ const response = await client.request(`${root}/session`, { method: 'DELETE' });
67
+ if (response.status !== 404)
68
+ await expectOk(response);
69
+ console.log('Teams disconnected from Skillshare. Browser login cookies are preserved for your next login.');
70
+ return;
71
+ }
72
+ if (action === 'access') {
73
+ const mode = args[1];
74
+ if (mode !== 'readOnly' && mode !== 'fullAccess')
75
+ throw new Error('Use teams access readOnly or teams access fullAccess.');
76
+ const response = await client.request(`${root}/access-mode`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ accessMode: mode }) });
77
+ await expectOk(response);
78
+ console.log(`Teams access mode: ${mode}`);
79
+ return;
80
+ }
81
+ if (action === 'upload') {
82
+ if (!args[1])
83
+ throw new Error('Use teams upload <file>.');
84
+ const path = resolve(args[1]);
85
+ const info = await stat(path);
86
+ if (!info.isFile() || info.size === 0)
87
+ throw new Error('Expected a nonempty regular file.');
88
+ const stream = createReadStream(path);
89
+ try {
90
+ const response = await client.request(`${root}/upload?fileName=${encodeURIComponent(basename(path))}`, {
91
+ method: 'POST', headers: { 'Content-Type': 'application/octet-stream', 'Content-Length': String(info.size) },
92
+ body: Readable.toWeb(stream), duplex: 'half',
93
+ });
94
+ await expectOk(response);
95
+ console.log(JSON.stringify(await response.json(), null, 2));
96
+ }
97
+ finally {
98
+ stream.destroy();
99
+ }
100
+ return;
101
+ }
102
+ if (action === 'download') {
103
+ if (!args[1] || !flags.output)
104
+ throw new Error('Use teams download <shared-file-webUrl|Skillshare-download-link> --output <new-file>.');
105
+ let target = new URL(args[1]);
106
+ if (target.origin !== new URL(client.baseUrl).origin) {
107
+ const linkResponse = await client.request(`${root}/download-link`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: args[1] }) });
108
+ await expectOk(linkResponse);
109
+ const link = await linkResponse.json();
110
+ target = new URL(link.url);
111
+ }
112
+ if (target.origin !== new URL(client.baseUrl).origin || target.pathname !== `${root}/download` || !target.searchParams.has('ticket'))
113
+ throw new Error('Expected a Skillshare Teams download link or direct SharePoint file URL.');
114
+ const path = resolve(flags.output);
115
+ const handle = await open(path, 'wx', 0o600);
116
+ try {
117
+ const response = await client.request(target.pathname + target.search, { method: 'GET', redirect: 'error' });
118
+ await expectOk(response);
119
+ if (!response.body)
120
+ throw new Error('The download response is empty.');
121
+ await pipeline(Readable.fromWeb(response.body), handle.createWriteStream());
122
+ }
123
+ catch (error) {
124
+ await handle.close().catch(() => { });
125
+ await rm(path, { force: true });
126
+ throw error;
127
+ }
128
+ finally {
129
+ await handle.close().catch(() => { });
130
+ }
131
+ console.log(`Downloaded to ${path}`);
132
+ return;
133
+ }
134
+ throw new Error('teams requires login, watch, logout, status, test, access, upload or download.');
135
+ }
136
+ export function assertCredentialDestination(value) {
137
+ const url = new URL(value);
138
+ if (url.username || url.password || url.search || url.hash || (url.protocol !== 'https:' &&
139
+ !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))) {
140
+ throw new Error('Teams credentials require an HTTPS Skillshare server, or HTTP on localhost.');
141
+ }
142
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quadient/skillshare",
3
- "version": "1.6.1",
3
+ "version": "1.7.0",
4
4
  "description": "Skillshare CLI for installing and publishing Quadient skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,7 @@
16
16
  "build": "rm -rf dist && tsc -p tsconfig.build.json",
17
17
  "start": "node ./dist/index.js",
18
18
  "prepublishOnly": "npm run build",
19
- "test": "tsx --test pages.test.ts pages-dev.test.ts"
19
+ "test": "tsx --test pages.test.ts pages-dev.test.ts teams-auth.test.ts teams-watch.test.ts teams-browser.test.ts"
20
20
  },
21
21
  "publishConfig": {
22
22
  "access": "public"
@@ -33,6 +33,7 @@
33
33
  "@clack/prompts": "^1.7.0",
34
34
  "chokidar": "4.0.3",
35
35
  "esbuild": "0.28.2",
36
- "jsonc-parser": "3.3.1"
36
+ "jsonc-parser": "3.3.1",
37
+ "playwright": "^1.63.0"
37
38
  }
38
39
  }