@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.
package/dist/index.js CHANGED
@@ -1,3 +1,7 @@
1
+ import { assertCredentialDestination, runTeamsCommand } from './teams.js';
2
+ import { formatUnknownCommand, parseCliArgs } from './cli-args.js';
3
+ import { findCommand, readCliVersion, renderCommandHelp, renderUsage } from './help.js';
4
+ import { inspectInstallTarget, installFilesAtomically } from './install-files.js';
1
5
  import { preparePageFiles } from './pages.js';
2
6
  import { runPageDev } from './pages-dev.js';
3
7
  import { createServer } from 'node:http';
@@ -5,9 +9,9 @@ import { createHash, randomBytes } from 'node:crypto';
5
9
  import { spawn } from 'node:child_process';
6
10
  import { existsSync } from 'node:fs';
7
11
  import { mkdir, open, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
8
- import { basename, dirname, join, relative, resolve } from 'node:path';
12
+ import { basename, join, relative, resolve } from 'node:path';
9
13
  import { homedir } from 'node:os';
10
- import { cancel, confirm, intro, isCancel, multiselect, outro, progress, select, spinner, text } from '@clack/prompts';
14
+ import { cancel, confirm, intro, isCancel, log, multiselect, outro, progress, select, spinner, text } from '@clack/prompts';
11
15
  const DEFAULT_URL = 'https://skillshare.quadientcloudpreprod.eu';
12
16
  // Records without a registry URL were created against UAT before the preprod migration.
13
17
  const LEGACY_UAT_URL = 'https://skillshare.inspireclouduat.net';
@@ -67,36 +71,7 @@ const AGENTS = [
67
71
  { value: 'pochi', label: 'Pochi', projectPath: '.pochi/skills', globalPath: '.pochi/skills' },
68
72
  { value: 'adal', label: 'AdaL', projectPath: '.adal/skills', globalPath: '.adal/skills' },
69
73
  ];
70
- const usage = `
71
- skillshare <command> [options]
72
-
73
- Commands:
74
- login [local]
75
- logout
76
- whoami
77
- list
78
- show <skillId> [version]
79
- upload <directory> --version <version> [--skill-id <id>] [--name <name> --description <desc>]
80
- install
81
- install <skillId|skillName> [version]
82
- install <skillId|skillName> [version] --target <directory>
83
- install --skill-version-id <id> --target <directory>
84
- update [skillId|skillName] [--target <directory>] [--dry-run]
85
- pages list
86
- pages show <slug>
87
- pages upload <directory> [--slug <slug>]
88
- pages dev [directory] [--slug <slug>] [--port <port>]
89
- [--build <command> --watch-dir <sources>] [--celld <executable>]
90
- [--user-id <id>] [--user-name <name>] [--user-email <email>]
91
- git configure [local]
92
- git unconfigure [local]
93
-
94
- Options:
95
- --token <token> Bearer token for API requests (overrides SKILLSHARE_TOKEN)
96
-
97
- Default server: ${DEFAULT_URL}
98
- A stored session keeps its server; run "skillshare login" to switch to the default.
99
- `;
74
+ const LOGIN_TIMEOUT_MS = 5 * 60_000;
100
75
  async function main() {
101
76
  const args = process.argv.slice(2);
102
77
  const command = args[0];
@@ -104,24 +79,52 @@ async function main() {
104
79
  await runInteractive();
105
80
  return;
106
81
  }
107
- if (command === '--help' || command === '-h') {
108
- console.log(usage.trim());
109
- process.exit(0);
82
+ if (command === '--version' || command === '-v') {
83
+ console.log(readCliVersion());
84
+ return;
85
+ }
86
+ if (command === '--help' || command === '-h' || command === 'help') {
87
+ const topic = command === 'help' ? findCommand(args[1]) : undefined;
88
+ if (command === 'help' && args[1] && !topic) {
89
+ throw new Error(formatUnknownCommand(args[1]));
90
+ }
91
+ console.log(topic ? renderCommandHelp(topic) : renderUsage(DEFAULT_URL));
92
+ return;
93
+ }
94
+ const commandSpec = findCommand(command);
95
+ if (!commandSpec) {
96
+ throw new Error(formatUnknownCommand(command));
97
+ }
98
+ // pages dev validates its own options.
99
+ const isPageDev = (command === 'pages' || command === 'page') && args[1] === 'dev';
100
+ const options = await parseOptions(args.slice(1), isPageDev ? undefined : command);
101
+ if (isFlagEnabled(options.flags, 'help')) {
102
+ console.log(renderCommandHelp(commandSpec));
103
+ return;
110
104
  }
111
- const options = await parseOptions(args.slice(1));
112
105
  if ((command === 'pages' || command === 'page') && options.positionals[0] === 'dev') {
113
106
  await runPageDev(options.positionals[1] ?? '.', options.flags);
114
107
  return;
115
108
  }
116
109
  switch (command) {
117
110
  case 'login': {
118
- await login(options.positionals[0] === 'local' ? LOCAL_URL : DEFAULT_URL);
111
+ const baseUrl = options.flags.server ?? (options.positionals[0] === 'local' ? LOCAL_URL : DEFAULT_URL);
112
+ assertCredentialDestination(baseUrl);
113
+ await login(baseUrl);
119
114
  return;
120
115
  }
121
116
  case 'logout': {
122
117
  await logoutSession();
123
118
  return;
124
119
  }
120
+ case 'teams': {
121
+ const baseUrl = options.flags.server ?? (options.positionals[1] === 'local' ? LOCAL_URL : await resolveActiveBaseUrl());
122
+ assertCredentialDestination(baseUrl);
123
+ if (!options.token && !await loadSession(baseUrl))
124
+ await login(baseUrl);
125
+ await runTeamsCommand(await createClient(baseUrl, options.token), options.positionals, options.flags, APP_DIR);
126
+ return;
127
+ }
125
128
  case 'git': {
126
129
  const action = options.positionals[0];
127
130
  const baseUrl = options.positionals[1] === 'local' ? LOCAL_URL : DEFAULT_URL;
@@ -203,6 +206,7 @@ async function main() {
203
206
  const skillRef = options.positionals[0];
204
207
  const version = options.positionals[1];
205
208
  const target = options.flags.target;
209
+ const force = isFlagEnabled(options.flags, 'force');
206
210
  if (skillVersionId) {
207
211
  if (!target) {
208
212
  throw new Error('install with --skill-version-id requires --target <directory>');
@@ -213,6 +217,7 @@ async function main() {
213
217
  }
214
218
  const detail = await client.get(`/api/skill-versions/${parsedSkillVersionId}`);
215
219
  const skill = await client.get(`/api/skills/${detail.skillId}`);
220
+ await ensureTargetsWritable(client.baseUrl, [target], { force });
216
221
  await client.post(`/api/skills/${detail.skillId}/versions/${detail.version}/downloads`);
217
222
  const installProgress = createInstallProgressReporter({
218
223
  skillName: skill.name,
@@ -242,6 +247,7 @@ async function main() {
242
247
  await runInteractiveInstall(client, {
243
248
  skillRef,
244
249
  version,
250
+ force,
245
251
  });
246
252
  return;
247
253
  }
@@ -255,6 +261,7 @@ async function main() {
255
261
  throw new Error(`Skill '${skill.name}' does not have a published version.`);
256
262
  }
257
263
  const detail = await client.get(`/api/skills/${skill.id}/versions/${effectiveVersion}`);
264
+ await ensureTargetsWritable(client.baseUrl, [target], { force });
258
265
  await client.post(`/api/skills/${skill.id}/versions/${effectiveVersion}/downloads`);
259
266
  const installProgress = createInstallProgressReporter({
260
267
  skillName: skill.name,
@@ -285,6 +292,7 @@ async function main() {
285
292
  skillRef: options.positionals[0],
286
293
  target: options.flags.target,
287
294
  dryRun: isFlagEnabled(options.flags, 'dry-run'),
295
+ force: isFlagEnabled(options.flags, 'force'),
288
296
  });
289
297
  return;
290
298
  }
@@ -331,23 +339,22 @@ async function main() {
331
339
  return;
332
340
  }
333
341
  default:
334
- throw new Error(`Unknown command '${command}'`);
342
+ throw new Error(formatUnknownCommand(command));
335
343
  }
336
344
  }
337
345
  async function runInteractive() {
338
- intro('Skillshare');
346
+ intro(`Skillshare ${readCliVersion()}`);
339
347
  const baseUrl = await resolveActiveBaseUrl();
340
- await ensureInteractiveLogin(baseUrl);
348
+ const user = await ensureInteractiveLogin(baseUrl);
341
349
  const client = await createClient(baseUrl);
342
- const pendingInstalledUpdates = await collectInstalledSkillUpdateCandidates(client);
350
+ log.info(`Signed in as ${user.displayName}${user.email ? ` <${user.email}>` : ''} · ${new URL(client.baseUrl).host}`);
351
+ const pendingInstalledUpdates = await checkInstalledSkillsForUpdates(client);
343
352
  if (pendingInstalledUpdates.length > 0) {
344
353
  const shouldUpdateInstalled = await promptConfirm(pendingInstalledUpdates.length === 1
345
354
  ? `There is 1 installed skill with a newer version available. Update it now?`
346
355
  : `There are ${pendingInstalledUpdates.length} installed skills with newer versions available. Update all of them now?`, true);
347
356
  if (shouldUpdateInstalled) {
348
357
  await updateInstalledSkills(client, { candidates: pendingInstalledUpdates });
349
- outro('Done.');
350
- return;
351
358
  }
352
359
  }
353
360
  if (existsSync(join(process.cwd(), SKILL_FILE_NAME))) {
@@ -367,12 +374,17 @@ async function runInteractive() {
367
374
  { value: 'install', label: 'Install a skill', hint: 'Browse registry and install to agents' },
368
375
  { value: 'update', label: 'Update installed skills', hint: 'Refresh recorded installs to the latest registry versions' },
369
376
  { value: 'upload', label: 'Upload a skill', hint: 'Create a new skill or publish a version' },
377
+ { value: 'exit', label: 'Exit' },
370
378
  ],
371
379
  });
372
380
  if (isCancel(action)) {
373
381
  cancel('Cancelled.');
374
382
  process.exit(0);
375
383
  }
384
+ if (action === 'exit') {
385
+ outro('Bye.');
386
+ return;
387
+ }
376
388
  if (action === 'install') {
377
389
  await runInteractiveInstall(client);
378
390
  }
@@ -384,22 +396,53 @@ async function runInteractive() {
384
396
  }
385
397
  outro('Done.');
386
398
  }
399
+ async function checkInstalledSkillsForUpdates(client) {
400
+ const records = (await readInstalledSkillRecords()).filter((record) => record.baseUrl === client.baseUrl);
401
+ if (records.length === 0) {
402
+ return [];
403
+ }
404
+ const status = spinner();
405
+ status.start(`Checking ${records.length} installed skill${records.length === 1 ? '' : 's'} for updates`);
406
+ const problems = [];
407
+ try {
408
+ const candidates = await collectInstalledSkillUpdateCandidates(client, {
409
+ records,
410
+ onSkipped(skip) {
411
+ if (skip.reason === 'removed' || skip.reason === 'error') {
412
+ problems.push(skip.message);
413
+ }
414
+ },
415
+ });
416
+ status.stop(candidates.length > 0
417
+ ? `${candidates.length} installed skill${candidates.length === 1 ? ' has' : 's have'} a newer version`
418
+ : 'Installed skills are up to date');
419
+ if (problems.length > 0) {
420
+ log.warn(problems.join('\n'));
421
+ }
422
+ return candidates;
423
+ }
424
+ catch (error) {
425
+ // The update check is optional; never let it block the main menu.
426
+ status.stop('Could not check installed skills for updates');
427
+ log.warn(error instanceof Error ? error.message : String(error));
428
+ return [];
429
+ }
430
+ }
387
431
  async function ensureInteractiveLogin(baseUrl) {
388
432
  const session = await loadSession(baseUrl);
389
- if (!session) {
390
- await login(baseUrl);
391
- return;
392
- }
393
- if (new Date(session.refreshTokenExpiresAt).getTime() <= Date.now()) {
394
- await login(baseUrl);
395
- return;
433
+ if (!session || new Date(session.refreshTokenExpiresAt).getTime() <= Date.now()) {
434
+ return login(baseUrl);
396
435
  }
397
436
  try {
398
437
  const client = await createClient(baseUrl);
399
- await client.get('/api/auth/me');
438
+ return await client.get('/api/auth/me');
400
439
  }
401
- catch {
402
- await login(baseUrl);
440
+ catch (error) {
441
+ // Offline is not an expired session; signing in again would not help.
442
+ if (error instanceof NetworkError) {
443
+ throw error;
444
+ }
445
+ return login(baseUrl);
403
446
  }
404
447
  }
405
448
  async function runInteractiveInstall(client, options) {
@@ -420,9 +463,13 @@ async function runInteractiveInstall(client, options) {
420
463
  throw new Error('Select at least one agent.');
421
464
  }
422
465
  const versionDetail = await client.get(`/api/skills/${skill.id}/versions/${selectedVersion}`);
423
- await client.post(`/api/skills/${skill.id}/versions/${selectedVersion}/downloads`);
424
466
  const installName = createInstalledSkillDirectoryName(skill);
425
467
  const installTargets = resolveInstallTargets(selectedAgents, scope, installName);
468
+ await ensureTargetsWritable(client.baseUrl, installTargets.map((target) => target.targetRoot), {
469
+ force: options?.force,
470
+ interactive: true,
471
+ });
472
+ await client.post(`/api/skills/${skill.id}/versions/${selectedVersion}/downloads`);
426
473
  const installProgress = createInstallProgressReporter({
427
474
  skillName: skill.name,
428
475
  version: selectedVersion,
@@ -446,7 +493,11 @@ async function runInteractiveInstall(client, options) {
446
493
  installProgress.clear();
447
494
  throw error;
448
495
  }
449
- console.log(`Installed ${skill.name}@${selectedVersion} to ${installTargets.length} location(s) for ${selectedAgents.length} agent(s) in ${scope} scope.`);
496
+ log.success([
497
+ `Installed ${skill.name}@${selectedVersion} for ${selectedAgents.length} agent${selectedAgents.length === 1 ? '' : 's'}:`,
498
+ ...installTargets.map((target) => ` ${formatInstallTargetRoot(target.targetRoot)}`),
499
+ 'Restart running agents to pick up the skill.',
500
+ ].join('\n'));
450
501
  }
451
502
  async function runInteractiveUpload(client, initialDirectory) {
452
503
  const directory = initialDirectory
@@ -504,6 +555,7 @@ async function updateInstalledSkills(client, options) {
504
555
  const targetFilter = normalizeText(options?.target);
505
556
  const normalizedTargetFilter = targetFilter ? resolve(targetFilter) : null;
506
557
  const dryRun = options?.dryRun ?? false;
558
+ const force = options?.force ?? false;
507
559
  let installedRecords = (await readInstalledSkillRecords())
508
560
  .filter((record) => record.baseUrl === client.baseUrl);
509
561
  if (skillRef) {
@@ -527,11 +579,11 @@ async function updateInstalledSkills(client, options) {
527
579
  const candidates = options?.candidates
528
580
  ?? await collectInstalledSkillUpdateCandidates(client, {
529
581
  records: installedRecords,
530
- onSkipped(message) {
531
- console.log(message);
582
+ onSkipped(skip) {
583
+ console.log(skip.message);
532
584
  },
533
585
  });
534
- const skippedCount = installedRecords.length - candidates.length;
586
+ let skippedCount = installedRecords.length - candidates.length;
535
587
  if (candidates.length === 0) {
536
588
  if (dryRun) {
537
589
  console.log(`Dry run finished. Would update 0, skipped ${skippedCount}.`);
@@ -545,9 +597,16 @@ async function updateInstalledSkills(client, options) {
545
597
  let plannedCount = 0;
546
598
  for (const candidate of candidates) {
547
599
  const { record, detail, latestVersion } = candidate;
600
+ const targetState = force ? null : await inspectInstallTarget(record.targetRoot, record);
601
+ if (targetState?.state === 'modified') {
602
+ skippedCount += 1;
603
+ console.log(`Skipping ${detail.name} in ${formatInstallTargetRoot(record.targetRoot)}: local changes in ${formatChangedPaths(targetState.paths)}. `
604
+ + `Run "skillshare update ${record.skillId} --target ${formatCliPath(record.targetRoot)} --force" to overwrite them.`);
605
+ continue;
606
+ }
548
607
  if (dryRun) {
549
608
  plannedCount += 1;
550
- console.log(`Would update ${detail.name} from ${record.skillVersion} to ${latestVersion} in ${record.targetRoot}`);
609
+ console.log(`Would update ${detail.name} from ${record.skillVersion} to ${latestVersion} in ${formatInstallTargetRoot(record.targetRoot)}`);
551
610
  continue;
552
611
  }
553
612
  const versionDetail = await client.get(`/api/skills/${record.skillId}/versions/${latestVersion}`);
@@ -574,7 +633,7 @@ async function updateInstalledSkills(client, options) {
574
633
  throw error;
575
634
  }
576
635
  updatedCount += 1;
577
- console.log(`Updated ${detail.name} from ${record.skillVersion} to ${latestVersion} in ${record.targetRoot}`);
636
+ console.log(`Updated ${detail.name} from ${record.skillVersion} to ${latestVersion} in ${formatInstallTargetRoot(record.targetRoot)}`);
578
637
  }
579
638
  if (dryRun) {
580
639
  console.log(`Dry run finished. Would update ${plannedCount}, skipped ${skippedCount}.`);
@@ -585,30 +644,59 @@ async function updateInstalledSkills(client, options) {
585
644
  async function collectInstalledSkillUpdateCandidates(client, options) {
586
645
  const records = options?.records
587
646
  ?? (await readInstalledSkillRecords()).filter((record) => record.baseUrl === client.baseUrl);
588
- const candidates = [];
589
- for (const record of records) {
590
- if (record.skillId == null) {
591
- options?.onSkipped?.(`Skipping ${record.skillName}: installed record in ${record.targetRoot} does not contain a skill id.`);
592
- continue;
647
+ const details = new Map();
648
+ // Each record is checked independently, so one removed skill or failed request does not hide the others.
649
+ const results = await mapWithConcurrency(records, 6, async (record) => {
650
+ const skillId = record.skillId;
651
+ if (skillId == null) {
652
+ return { record, reason: 'no-id', message: `Skipping ${record.skillName}: installed record in ${record.targetRoot} does not contain a skill id.` };
653
+ }
654
+ let detail;
655
+ try {
656
+ if (!details.has(skillId)) {
657
+ details.set(skillId, client.get(`/api/skills/${skillId}`));
658
+ }
659
+ detail = await details.get(skillId);
660
+ }
661
+ catch (error) {
662
+ if (error instanceof HttpError && error.status === 404) {
663
+ return { record, reason: 'removed', message: `${record.skillName || `Skill ${skillId}`} in ${formatInstallTargetRoot(record.targetRoot)} no longer exists in the registry.` };
664
+ }
665
+ return { record, reason: 'error', message: `Could not check ${record.skillName || `skill ${skillId}`}: ${error instanceof Error ? error.message : String(error)}` };
593
666
  }
594
- const detail = await client.get(`/api/skills/${record.skillId}`);
595
667
  const latestVersion = normalizeText(detail.latestVersion);
596
668
  if (!latestVersion) {
597
- options?.onSkipped?.(`Skipping ${record.skillName}: no latest version is published.`);
598
- continue;
669
+ return { record, reason: 'no-version', message: `Skipping ${record.skillName}: no latest version is published.` };
599
670
  }
600
671
  if (latestVersion === normalizeText(record.skillVersion)) {
601
- options?.onSkipped?.(`Skipping ${record.skillName}: ${latestVersion} is already installed in ${record.targetRoot}.`);
602
- continue;
672
+ return { record, reason: 'current', message: `Skipping ${record.skillName}: ${latestVersion} is already installed in ${formatInstallTargetRoot(record.targetRoot)}.` };
673
+ }
674
+ return { record, detail, latestVersion };
675
+ });
676
+ const candidates = [];
677
+ for (const result of results) {
678
+ if ('reason' in result) {
679
+ options?.onSkipped?.(result);
680
+ }
681
+ else {
682
+ candidates.push(result);
603
683
  }
604
- candidates.push({
605
- record,
606
- detail,
607
- latestVersion,
608
- });
609
684
  }
610
685
  return candidates;
611
686
  }
687
+ async function mapWithConcurrency(items, limit, worker) {
688
+ const results = new Array(items.length);
689
+ let nextIndex = 0;
690
+ async function run() {
691
+ while (nextIndex < items.length) {
692
+ const index = nextIndex;
693
+ nextIndex += 1;
694
+ results[index] = await worker(items[index]);
695
+ }
696
+ }
697
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, run));
698
+ return results;
699
+ }
612
700
  function formatUpdateSelectionDescription(options) {
613
701
  const parts = [];
614
702
  if (options.skillRef) {
@@ -729,7 +817,7 @@ async function promptForAgents(scope) {
729
817
  })),
730
818
  ],
731
819
  initialValues,
732
- required: false,
820
+ required: true,
733
821
  });
734
822
  if (isCancel(selected)) {
735
823
  cancel('Cancelled.');
@@ -865,31 +953,81 @@ async function publishSkillFromDirectory(client, localSkill, input) {
865
953
  }
866
954
  async function writeSkillVersionToDirectory(client, input, installProgress) {
867
955
  installProgress?.onTargetStart(input.targetRoot);
868
- for (const file of input.files) {
869
- installProgress?.onFileStart(input.targetRoot, file.nameWithPath, file.length);
870
- const response = await client.rawGet(`/api/skills/${input.skillId}/versions/${input.version}/files/${file.nameWithPath}`);
871
- const destination = join(input.targetRoot, file.nameWithPath);
872
- await mkdir(dirname(destination), { recursive: true });
873
- await downloadResponseToFile(response, destination, (bytes) => {
874
- installProgress?.onBytesTransferred(bytes);
875
- });
876
- installProgress?.onFileComplete();
877
- }
956
+ const previous = await findInstalledSkillRecord(client.baseUrl, input.targetRoot);
957
+ const expectedSizes = new Map(input.files.map((file) => [file.nameWithPath, file.length]));
958
+ const manifest = await installFilesAtomically({
959
+ targetRoot: input.targetRoot,
960
+ files: input.files.map((file) => ({ relativePath: file.nameWithPath })),
961
+ previousManifest: previous?.files,
962
+ async download(relativePath, destination) {
963
+ installProgress?.onFileStart(input.targetRoot, relativePath, expectedSizes.get(relativePath) ?? 0);
964
+ const response = await client.rawGet(`/api/skills/${input.skillId}/versions/${input.version}/files/${relativePath}`);
965
+ const hash = await downloadResponseToFile(response, destination, (bytes) => {
966
+ installProgress?.onBytesTransferred(bytes);
967
+ });
968
+ installProgress?.onFileComplete();
969
+ return hash;
970
+ },
971
+ });
878
972
  await upsertInstalledSkillRecord(client.baseUrl, input.targetRoot, {
879
973
  skillId: input.skillId,
880
974
  skillName: input.skillName,
881
975
  skillDescription: input.skillDescription,
882
976
  skillVersion: input.version,
883
- });
977
+ }, manifest);
884
978
  installProgress?.onTargetComplete();
885
979
  }
980
+ async function findInstalledSkillRecord(baseUrl, targetRoot) {
981
+ const resolvedTargetRoot = resolve(targetRoot);
982
+ const records = (await readInstalledSkillRecords()).filter((record) => record.targetRoot === resolvedTargetRoot);
983
+ return records.find((record) => record.baseUrl === normalizeBaseUrl(baseUrl)) ?? records[0] ?? null;
984
+ }
985
+ async function ensureTargetsWritable(baseUrl, targetRoots, options) {
986
+ if (options.force) {
987
+ return;
988
+ }
989
+ const conflicts = [];
990
+ for (const targetRoot of targetRoots) {
991
+ // Directories installed before installed.json existed still carry the legacy metadata file.
992
+ const record = await findInstalledSkillRecord(baseUrl, targetRoot)
993
+ ?? (existsSync(join(targetRoot, SKILL_METADATA_FILE_NAME)) ? {} : null);
994
+ const state = await inspectInstallTarget(targetRoot, record);
995
+ if (state.state === 'modified') {
996
+ conflicts.push(` ${formatInstallTargetRoot(targetRoot)}: local changes in ${formatChangedPaths(state.paths)}`);
997
+ }
998
+ else if (state.state === 'untracked') {
999
+ conflicts.push(` ${formatInstallTargetRoot(targetRoot)}: contains files not installed by Skillshare`);
1000
+ }
1001
+ }
1002
+ if (conflicts.length === 0) {
1003
+ return;
1004
+ }
1005
+ if (options.interactive) {
1006
+ log.warn(`These install locations would be overwritten:\n${conflicts.join('\n')}`);
1007
+ if (await promptConfirm('Overwrite them?', false)) {
1008
+ return;
1009
+ }
1010
+ cancel('Install cancelled. Nothing was changed.');
1011
+ process.exit(0);
1012
+ }
1013
+ throw new Error(`Refusing to overwrite:\n${conflicts.join('\n')}\nRe-run with --force to overwrite.`);
1014
+ }
1015
+ function formatChangedPaths(paths) {
1016
+ const shown = paths.slice(0, 3).join(', ');
1017
+ return paths.length > 3 ? `${shown} and ${paths.length - 3} more` : shown;
1018
+ }
1019
+ function formatCliPath(path) {
1020
+ const display = formatInstallTargetRoot(path);
1021
+ return /^[A-Za-z0-9._/~-]+$/.test(display) ? display : JSON.stringify(display);
1022
+ }
886
1023
  async function downloadResponseToFile(response, destination, onBytesTransferred) {
1024
+ const hash = createHash('sha256');
887
1025
  const body = response.body;
888
1026
  if (!body) {
889
1027
  const buffer = Buffer.from(await response.arrayBuffer());
890
1028
  await writeFile(destination, buffer);
891
1029
  onBytesTransferred?.(buffer.length);
892
- return;
1030
+ return hash.update(buffer).digest('hex');
893
1031
  }
894
1032
  const fileHandle = await open(destination, 'w');
895
1033
  let failed = true;
@@ -905,6 +1043,7 @@ async function downloadResponseToFile(response, destination, onBytesTransferred)
905
1043
  continue;
906
1044
  }
907
1045
  await fileHandle.writeFile(value);
1046
+ hash.update(value);
908
1047
  onBytesTransferred?.(value.length);
909
1048
  }
910
1049
  }
@@ -914,6 +1053,7 @@ async function downloadResponseToFile(response, destination, onBytesTransferred)
914
1053
  await rm(destination, { force: true });
915
1054
  }
916
1055
  }
1056
+ return hash.digest('hex');
917
1057
  }
918
1058
  function createInstallProgressReporter(input) {
919
1059
  const installStatus = spinner({ indicator: 'timer' });
@@ -1329,9 +1469,15 @@ async function login(baseUrl) {
1329
1469
  const loginUrl = `${baseUrl}/api/cli/login?redirectUri=${encodeURIComponent(callback.redirectUri)}` +
1330
1470
  `&codeChallenge=${encodeURIComponent(codeChallenge)}` +
1331
1471
  `&state=${encodeURIComponent(state)}`;
1332
- await openBrowser(loginUrl);
1333
- console.log(`Opening browser for ${baseUrl}`);
1334
- const authCode = await callback.wait(state);
1472
+ console.log(`Opening the browser to sign in to ${baseUrl}.`);
1473
+ console.log(`If it does not open, visit this URL on this computer:\n ${loginUrl}`);
1474
+ try {
1475
+ await openBrowser(loginUrl);
1476
+ }
1477
+ catch {
1478
+ console.log('Could not open a browser automatically. Use the URL above.');
1479
+ }
1480
+ const authCode = await callback.wait(state, LOGIN_TIMEOUT_MS);
1335
1481
  const response = await fetchJson(`${baseUrl}/api/cli/token`, {
1336
1482
  method: 'POST',
1337
1483
  headers: {
@@ -1353,6 +1499,7 @@ async function login(baseUrl) {
1353
1499
  user: response.user,
1354
1500
  });
1355
1501
  console.log(`Logged in as ${response.user.displayName} (${baseUrl})`);
1502
+ return response.user;
1356
1503
  }
1357
1504
  const GCM_MINIMUM_VERSION = [2, 0, 931];
1358
1505
  async function configureGitCredentialManager(baseUrl) {
@@ -1414,32 +1561,8 @@ function compareVersions(left, right) {
1414
1561
  function formatVersion(version) {
1415
1562
  return version.join('.');
1416
1563
  }
1417
- async function parseOptions(args) {
1418
- const booleanFlags = new Set(['dry-run']);
1419
- const flags = {};
1420
- const positionals = [];
1421
- for (let index = 0; index < args.length; index += 1) {
1422
- const current = args[index];
1423
- if (!current) {
1424
- continue;
1425
- }
1426
- if (current.startsWith('--')) {
1427
- const key = current.slice(2);
1428
- const value = args[index + 1];
1429
- if (!value || value.startsWith('--')) {
1430
- if (booleanFlags.has(key)) {
1431
- flags[key] = 'true';
1432
- continue;
1433
- }
1434
- throw new Error(`Missing value for ${current}`);
1435
- }
1436
- flags[key] = value;
1437
- index += 1;
1438
- }
1439
- else {
1440
- positionals.push(current);
1441
- }
1442
- }
1564
+ async function parseOptions(args, commandName) {
1565
+ const { flags, positionals } = parseCliArgs(args, commandName);
1443
1566
  const tokenFromFlag = normalizeText(flags.token);
1444
1567
  const tokenFromEnvironment = normalizeText(process.env.SKILLSHARE_TOKEN);
1445
1568
  return {
@@ -1476,7 +1599,7 @@ async function createClient(baseUrl, explicitToken) {
1476
1599
  if (new Date(currentSession.refreshTokenExpiresAt).getTime() <= Date.now()) {
1477
1600
  throw new Error(`Stored session for ${resolvedBaseUrl} is no longer valid. Run "skillshare login${resolvedBaseUrl === LOCAL_URL ? ' local' : ''}" again.`);
1478
1601
  }
1479
- const response = await fetch(`${resolvedBaseUrl}/api/cli/refresh`, {
1602
+ const response = await fetchFromServer(`${resolvedBaseUrl}/api/cli/refresh`, {
1480
1603
  method: 'POST',
1481
1604
  headers: {
1482
1605
  'Content-Type': 'application/json',
@@ -1513,7 +1636,7 @@ async function createClient(baseUrl, explicitToken) {
1513
1636
  return refreshedSession.accessToken;
1514
1637
  }
1515
1638
  async function sendAuthorized(path, init, retryUnauthorized = true) {
1516
- const response = await fetch(`${resolvedBaseUrl}${path}`, {
1639
+ const response = await fetchFromServer(`${resolvedBaseUrl}${path}`, {
1517
1640
  ...init,
1518
1641
  headers: await buildHeaders(() => getAccessToken(), init?.headers),
1519
1642
  });
@@ -1521,7 +1644,7 @@ async function createClient(baseUrl, explicitToken) {
1521
1644
  return response;
1522
1645
  }
1523
1646
  const refreshedToken = await getAccessToken({ forceRefresh: true });
1524
- return fetch(`${resolvedBaseUrl}${path}`, {
1647
+ return fetchFromServer(`${resolvedBaseUrl}${path}`, {
1525
1648
  ...init,
1526
1649
  headers: await buildHeaders(() => Promise.resolve(refreshedToken), init?.headers),
1527
1650
  });
@@ -1538,10 +1661,14 @@ async function createClient(baseUrl, explicitToken) {
1538
1661
  });
1539
1662
  return parseJson(response);
1540
1663
  },
1664
+ async request(path, init) {
1665
+ // Streaming uploads cannot be replayed on an expired Skillshare access token.
1666
+ return sendAuthorized(path, { ...init, redirect: 'error' }, typeof init.body === 'string' || !init.body);
1667
+ },
1541
1668
  async rawGet(path) {
1542
1669
  const response = await sendAuthorized(path);
1543
1670
  if (!response.ok) {
1544
- throw new Error(await formatErrorResponse(response));
1671
+ throw new HttpError(response.status, await formatErrorResponse(response));
1545
1672
  }
1546
1673
  return response;
1547
1674
  },
@@ -1596,9 +1723,34 @@ async function buildHeaders(getAccessToken, initialHeaders) {
1596
1723
  headers.set('Authorization', `Bearer ${await getAccessToken()}`);
1597
1724
  return headers;
1598
1725
  }
1726
+ class HttpError extends Error {
1727
+ status;
1728
+ constructor(status, message) {
1729
+ super(message);
1730
+ this.status = status;
1731
+ this.name = 'HttpError';
1732
+ }
1733
+ }
1734
+ class NetworkError extends Error {
1735
+ constructor(origin, cause) {
1736
+ super(`Cannot reach ${origin}. Check your network or VPN connection and try again.`, { cause });
1737
+ this.name = 'NetworkError';
1738
+ }
1739
+ }
1740
+ async function fetchFromServer(input, init) {
1741
+ try {
1742
+ return await fetch(input, init);
1743
+ }
1744
+ catch (error) {
1745
+ if (init?.signal?.aborted) {
1746
+ throw error;
1747
+ }
1748
+ throw new NetworkError(new URL(input).origin, error);
1749
+ }
1750
+ }
1599
1751
  async function parseJson(response) {
1600
1752
  if (!response.ok) {
1601
- throw new Error(await formatErrorResponse(response));
1753
+ throw new HttpError(response.status, await formatErrorResponse(response));
1602
1754
  }
1603
1755
  return response.json();
1604
1756
  }
@@ -1633,7 +1785,7 @@ async function formatErrorResponse(response) {
1633
1785
  : `${fallbackMessage} ${body}`;
1634
1786
  }
1635
1787
  async function fetchJson(input, init) {
1636
- const response = await fetch(input, init);
1788
+ const response = await fetchFromServer(input, init);
1637
1789
  return parseJson(response);
1638
1790
  }
1639
1791
  async function loadSession(baseUrl) {
@@ -1739,15 +1891,22 @@ async function waitForLoginCallback() {
1739
1891
  }
1740
1892
  return {
1741
1893
  redirectUri: `http://127.0.0.1:${address.port}/callback`,
1742
- async wait(expectedState) {
1894
+ async wait(expectedState, timeoutMs) {
1895
+ let timer;
1896
+ const timeout = new Promise((_, reject) => {
1897
+ timer = setTimeout(() => {
1898
+ reject(new Error(`Sign-in did not complete within ${Math.round(timeoutMs / 60_000)} minutes. Run "skillshare login" again.`));
1899
+ }, timeoutMs);
1900
+ });
1743
1901
  try {
1744
- const payload = JSON.parse(await statePromise);
1902
+ const payload = JSON.parse(await Promise.race([statePromise, timeout]));
1745
1903
  if (payload.state !== expectedState) {
1746
1904
  throw new Error('Login state mismatch.');
1747
1905
  }
1748
1906
  return payload.code;
1749
1907
  }
1750
1908
  finally {
1909
+ clearTimeout(timer);
1751
1910
  server.close();
1752
1911
  }
1753
1912
  },
@@ -1804,7 +1963,7 @@ async function upsertUploadedSkillMetadata(baseUrl, directory, metadata) {
1804
1963
  await writeStateFile(UPLOADED_STATE_PATH, records);
1805
1964
  await removeLegacyLocalSkillMetadata(resolvedDirectory);
1806
1965
  }
1807
- async function upsertInstalledSkillRecord(baseUrl, targetRoot, metadata) {
1966
+ async function upsertInstalledSkillRecord(baseUrl, targetRoot, metadata, files) {
1808
1967
  const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
1809
1968
  const resolvedTargetRoot = resolve(targetRoot);
1810
1969
  const records = (await readInstalledSkillRecords())
@@ -1814,6 +1973,7 @@ async function upsertInstalledSkillRecord(baseUrl, targetRoot, metadata) {
1814
1973
  targetRoot: resolvedTargetRoot,
1815
1974
  baseUrl: normalizedBaseUrl,
1816
1975
  updatedAt: new Date().toISOString(),
1976
+ ...(files ? { files } : {}),
1817
1977
  });
1818
1978
  await writeStateFile(INSTALLED_STATE_PATH, records);
1819
1979
  await removeLegacyLocalSkillMetadata(resolvedTargetRoot);
@@ -1855,9 +2015,17 @@ async function readInstalledSkillRecords() {
1855
2015
  skillName: typeof entry.skillName === 'string' ? entry.skillName : '',
1856
2016
  skillDescription: typeof entry.skillDescription === 'string' ? entry.skillDescription : '',
1857
2017
  skillVersion: typeof entry.skillVersion === 'string' ? entry.skillVersion : '',
2018
+ files: readFileManifest(entry.files),
1858
2019
  }].filter((record) => record.targetRoot.length > 0);
1859
2020
  });
1860
2021
  }
2022
+ function readFileManifest(value) {
2023
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
2024
+ return undefined;
2025
+ }
2026
+ const entries = Object.entries(value).filter((entry) => typeof entry[1] === 'string');
2027
+ return Object.fromEntries(entries);
2028
+ }
1861
2029
  async function readStateFile(path) {
1862
2030
  try {
1863
2031
  const parsed = JSON.parse(await readFile(path, 'utf8'));