@quadient/skillshare 1.6.2 → 1.8.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.
@@ -0,0 +1,111 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { createReadStream } from 'node:fs';
3
+ import { mkdir, readdir, rename, rm, rmdir, stat } from 'node:fs/promises';
4
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
5
+ export function resolveInsideRoot(root, relativePath) {
6
+ const resolvedRoot = resolve(root);
7
+ const destination = resolve(resolvedRoot, relativePath);
8
+ if (destination === resolvedRoot || !destination.startsWith(resolvedRoot + sep)) {
9
+ throw new Error(`Refusing to write '${relativePath}' outside ${resolvedRoot}.`);
10
+ }
11
+ return destination;
12
+ }
13
+ export async function hashFile(path) {
14
+ const hash = createHash('sha256');
15
+ for await (const chunk of createReadStream(path)) {
16
+ hash.update(chunk);
17
+ }
18
+ return hash.digest('hex');
19
+ }
20
+ /**
21
+ * Compares a target directory with the manifest recorded at install time.
22
+ * A record without a manifest predates change tracking and cannot be verified.
23
+ */
24
+ export async function inspectInstallTarget(targetRoot, record) {
25
+ if (!await isNonEmptyDirectory(targetRoot)) {
26
+ return { state: 'missing' };
27
+ }
28
+ if (!record) {
29
+ return { state: 'untracked' };
30
+ }
31
+ if (!record.files) {
32
+ return { state: 'legacy' };
33
+ }
34
+ const modified = [];
35
+ for (const [relativePath, expectedHash] of Object.entries(record.files)) {
36
+ const path = resolveInsideRoot(targetRoot, relativePath);
37
+ try {
38
+ if (await hashFile(path) !== expectedHash)
39
+ modified.push(relativePath);
40
+ }
41
+ catch (error) {
42
+ if (error?.code !== 'ENOENT')
43
+ throw error;
44
+ modified.push(relativePath);
45
+ }
46
+ }
47
+ return modified.length > 0 ? { state: 'modified', paths: modified } : { state: 'clean' };
48
+ }
49
+ /**
50
+ * Downloads every file into a sibling staging directory first, so a failed transfer
51
+ * leaves the existing install untouched. Files from the previous manifest that the
52
+ * new version no longer contains are removed afterwards.
53
+ */
54
+ export async function installFilesAtomically(input) {
55
+ const targetRoot = resolve(input.targetRoot);
56
+ const stagingRoot = join(dirname(targetRoot), `.${basename(targetRoot)}.skillshare-${randomBytes(4).toString('hex')}`);
57
+ const manifest = {};
58
+ await mkdir(stagingRoot, { recursive: true });
59
+ try {
60
+ for (const file of input.files) {
61
+ const stagedPath = resolveInsideRoot(stagingRoot, file.relativePath);
62
+ resolveInsideRoot(targetRoot, file.relativePath);
63
+ await mkdir(dirname(stagedPath), { recursive: true });
64
+ manifest[normalizeRelativePath(file.relativePath)] = await input.download(file.relativePath, stagedPath);
65
+ }
66
+ await mkdir(targetRoot, { recursive: true });
67
+ for (const relativePath of Object.keys(manifest)) {
68
+ const destination = resolveInsideRoot(targetRoot, relativePath);
69
+ await mkdir(dirname(destination), { recursive: true });
70
+ await rename(resolveInsideRoot(stagingRoot, relativePath), destination);
71
+ }
72
+ for (const relativePath of Object.keys(input.previousManifest ?? {})) {
73
+ if (relativePath in manifest)
74
+ continue;
75
+ const stalePath = resolveInsideRoot(targetRoot, relativePath);
76
+ await rm(stalePath, { force: true });
77
+ await removeEmptyParents(dirname(stalePath), targetRoot);
78
+ }
79
+ }
80
+ finally {
81
+ await rm(stagingRoot, { recursive: true, force: true });
82
+ }
83
+ return manifest;
84
+ }
85
+ function normalizeRelativePath(path) {
86
+ return relative('.', join('.', path)).replaceAll('\\', '/');
87
+ }
88
+ async function isNonEmptyDirectory(path) {
89
+ try {
90
+ if (!(await stat(path)).isDirectory())
91
+ return true;
92
+ return (await readdir(path)).length > 0;
93
+ }
94
+ catch (error) {
95
+ if (error?.code === 'ENOENT')
96
+ return false;
97
+ throw error;
98
+ }
99
+ }
100
+ async function removeEmptyParents(directory, root) {
101
+ let current = resolve(directory);
102
+ while (current !== root && current.startsWith(root + sep)) {
103
+ try {
104
+ await rmdir(current);
105
+ }
106
+ catch {
107
+ return;
108
+ }
109
+ current = dirname(current);
110
+ }
111
+ }
@@ -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.8.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 cli-args.test.ts install-files.test.ts 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
  }