@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 +35 -3
- package/dist/index.js +40 -7
- package/dist/teams-auth.js +304 -0
- package/dist/teams-watch.js +45 -0
- package/dist/teams.js +142 -0
- package/package.json +4 -3
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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 :
|
|
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
|
|
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) :
|
|
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) :
|
|
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.
|
|
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
|
}
|