@quadient/skillshare 1.6.2 → 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,6 +19,35 @@ 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
 
24
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`.
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';
@@ -88,11 +89,20 @@ Commands:
88
89
  pages dev [directory] [--slug <slug>] [--port <port>]
89
90
  [--build <command> --watch-dir <sources>] [--celld <executable>]
90
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>
91
100
  git configure [local]
92
101
  git unconfigure [local]
93
102
 
94
103
  Options:
95
104
  --token <token> Bearer token for API requests (overrides SKILLSHARE_TOKEN)
105
+ --server <url> Skillshare origin for login and Teams commands
96
106
 
97
107
  Default server: ${DEFAULT_URL}
98
108
  A stored session keeps its server; run "skillshare login" to switch to the default.
@@ -115,13 +125,23 @@ async function main() {
115
125
  }
116
126
  switch (command) {
117
127
  case 'login': {
118
- await login(options.positionals[0] === 'local' ? LOCAL_URL : DEFAULT_URL);
128
+ const baseUrl = options.flags.server ?? (options.positionals[0] === 'local' ? LOCAL_URL : DEFAULT_URL);
129
+ assertCredentialDestination(baseUrl);
130
+ await login(baseUrl);
119
131
  return;
120
132
  }
121
133
  case 'logout': {
122
134
  await logoutSession();
123
135
  return;
124
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
+ }
125
145
  case 'git': {
126
146
  const action = options.positionals[0];
127
147
  const baseUrl = options.positionals[1] === 'local' ? LOCAL_URL : DEFAULT_URL;
@@ -1415,7 +1435,7 @@ function formatVersion(version) {
1415
1435
  return version.join('.');
1416
1436
  }
1417
1437
  async function parseOptions(args) {
1418
- const booleanFlags = new Set(['dry-run']);
1438
+ const booleanFlags = new Set(['dry-run', 'headless']);
1419
1439
  const flags = {};
1420
1440
  const positionals = [];
1421
1441
  for (let index = 0; index < args.length; index += 1) {
@@ -1426,6 +1446,10 @@ async function parseOptions(args) {
1426
1446
  if (current.startsWith('--')) {
1427
1447
  const key = current.slice(2);
1428
1448
  const value = args[index + 1];
1449
+ if (key === 'headless' && value !== 'true' && value !== 'false') {
1450
+ flags[key] = 'true';
1451
+ continue;
1452
+ }
1429
1453
  if (!value || value.startsWith('--')) {
1430
1454
  if (booleanFlags.has(key)) {
1431
1455
  flags[key] = 'true';
@@ -1538,6 +1562,10 @@ async function createClient(baseUrl, explicitToken) {
1538
1562
  });
1539
1563
  return parseJson(response);
1540
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
+ },
1541
1569
  async rawGet(path) {
1542
1570
  const response = await sendAuthorized(path);
1543
1571
  if (!response.ok) {
@@ -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.2",
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
  }