@quadient/skillshare 1.7.0 → 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/README.md CHANGED
@@ -50,6 +50,14 @@ permissions, Microsoft session lifetime and file-tool differences in the hosted
50
50
 
51
51
  ## Behavior
52
52
 
53
+ Starting with 1.8.0:
54
+
55
+ - `install` and `update` download into a staging directory first, so a failed transfer leaves the existing install untouched. Files that a new version no longer contains are removed.
56
+ - `installed.json` records a SHA-256 manifest of installed files. `update` skips installs with local changes and `install` refuses to overwrite them, or a directory that Skillshare did not install, unless you confirm interactively or pass `--force`. Installs recorded by older versions are not checked until they are updated once.
57
+ - Interactive mode shows the signed-in user and server, checks installed skills in parallel, and reports removed skills or failed checks as warnings instead of stopping.
58
+ - `login` prints the sign-in URL in case no browser opens, and stops waiting after 5 minutes.
59
+ - Unknown options are rejected with a suggestion. `skillshare <command> --help`, `skillshare help <command>` and `skillshare --version` are available.
60
+
53
61
  - 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
62
  - 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
63
  - Uploaded/installed records retain their registry URL. Legacy records without a URL still belong to UAT and are not silently reassigned to preprod.
@@ -0,0 +1,86 @@
1
+ import { BOOLEAN_FLAGS, COMMANDS, GLOBAL_FLAGS, findCommand } from './help.js';
2
+ /**
3
+ * Parses options for a command. With a command name, unknown options are rejected
4
+ * before a value is read, so a typo gets a suggestion instead of a missing-value error.
5
+ */
6
+ export function parseCliArgs(args, commandName) {
7
+ const flags = {};
8
+ const positionals = [];
9
+ for (let index = 0; index < args.length; index += 1) {
10
+ const current = args[index];
11
+ if (!current)
12
+ continue;
13
+ if (current === '-h') {
14
+ flags.help = 'true';
15
+ continue;
16
+ }
17
+ if (!current.startsWith('--') || current === '--') {
18
+ positionals.push(current);
19
+ continue;
20
+ }
21
+ const separator = current.indexOf('=');
22
+ const key = separator > 0 ? current.slice(2, separator) : current.slice(2);
23
+ if (commandName) {
24
+ assertKnownFlag(commandName, key);
25
+ }
26
+ if (separator > 0) {
27
+ flags[key] = current.slice(separator + 1);
28
+ continue;
29
+ }
30
+ const value = args[index + 1];
31
+ if (BOOLEAN_FLAGS.has(key)) {
32
+ // Booleans never consume a following positional, only an explicit true/false.
33
+ if (value === 'true' || value === 'false') {
34
+ flags[key] = value;
35
+ index += 1;
36
+ }
37
+ else {
38
+ flags[key] = 'true';
39
+ }
40
+ continue;
41
+ }
42
+ if (value === undefined || value.startsWith('--')) {
43
+ throw new Error(`Missing value for ${current}.`);
44
+ }
45
+ flags[key] = value;
46
+ index += 1;
47
+ }
48
+ return { flags, positionals };
49
+ }
50
+ function assertKnownFlag(commandName, name) {
51
+ const command = findCommand(commandName);
52
+ if (!command)
53
+ return;
54
+ const allowed = [...GLOBAL_FLAGS, ...command.flags];
55
+ if (allowed.includes(name))
56
+ return;
57
+ const suggestion = suggest(name, allowed);
58
+ throw new Error(`Unknown option --${name} for "${command.name}".${suggestion ? ` Did you mean --${suggestion}?` : ''} Run "skillshare ${command.name} --help" for options.`);
59
+ }
60
+ export function formatUnknownCommand(name) {
61
+ const suggestion = suggest(name, COMMANDS.map((command) => command.name));
62
+ return `Unknown command '${name}'.${suggestion ? ` Did you mean '${suggestion}'?` : ''} Run "skillshare --help" for the list of commands.`;
63
+ }
64
+ export function suggest(input, candidates) {
65
+ let best = null;
66
+ for (const candidate of candidates) {
67
+ const distance = candidate.startsWith(input) || input.startsWith(candidate) ? 1 : levenshtein(input, candidate);
68
+ if (distance <= 2 && (!best || distance < best.distance)) {
69
+ best = { candidate, distance };
70
+ }
71
+ }
72
+ return best?.candidate ?? null;
73
+ }
74
+ function levenshtein(left, right) {
75
+ const previous = Array.from({ length: right.length + 1 }, (_, index) => index);
76
+ for (let i = 1; i <= left.length; i += 1) {
77
+ let diagonal = previous[0];
78
+ previous[0] = i;
79
+ for (let j = 1; j <= right.length; j += 1) {
80
+ const above = previous[j];
81
+ previous[j] = Math.min(above + 1, previous[j - 1] + 1, diagonal + (left[i - 1] === right[j - 1] ? 0 : 1));
82
+ diagonal = above;
83
+ }
84
+ }
85
+ return previous[right.length];
86
+ }
package/dist/help.js ADDED
@@ -0,0 +1,167 @@
1
+ import { readFileSync } from 'node:fs';
2
+ export const GLOBAL_FLAGS = ['token', 'server', 'help'];
3
+ export const BOOLEAN_FLAGS = new Set(['dry-run', 'force', 'headless', 'help']);
4
+ export const COMMANDS = [
5
+ {
6
+ name: 'login',
7
+ summary: 'Sign in through the browser and store a CLI session',
8
+ usage: ['login [local] [--server <url>]'],
9
+ description: 'Opens the Skillshare sign-in page. If no browser opens, copy the printed URL into one.',
10
+ examples: ['skillshare login', 'skillshare login local'],
11
+ flags: [],
12
+ },
13
+ {
14
+ name: 'logout',
15
+ summary: 'Remove the stored CLI session',
16
+ usage: ['logout'],
17
+ flags: [],
18
+ },
19
+ {
20
+ name: 'whoami',
21
+ summary: 'Show the signed-in user',
22
+ usage: ['whoami'],
23
+ flags: [],
24
+ },
25
+ {
26
+ name: 'list',
27
+ summary: 'List skills in the registry',
28
+ usage: ['list'],
29
+ flags: [],
30
+ },
31
+ {
32
+ name: 'show',
33
+ summary: 'Show a skill or one of its versions',
34
+ usage: ['show <skillId> [version]'],
35
+ flags: [],
36
+ },
37
+ {
38
+ name: 'install',
39
+ summary: 'Install a skill for your coding agents',
40
+ usage: [
41
+ 'install [skillId|skillName] [version]',
42
+ 'install <skillId|skillName> [version] --target <directory> [--force]',
43
+ 'install --skill-version-id <id> --target <directory> [--force]',
44
+ ],
45
+ description: 'Without --target the CLI asks for the version, scope and agents. Files that you changed locally are not overwritten unless you confirm or pass --force.',
46
+ options: [
47
+ ['--target <directory>', 'Install into this directory instead of agent skill folders'],
48
+ ['--skill-version-id <id>', 'Install an exact published skill version'],
49
+ ['--force', 'Overwrite local changes in the target directory'],
50
+ ],
51
+ examples: ['skillshare install', 'skillshare install "Prompt Lint"', 'skillshare install 12 0.1.0 --target ./installed'],
52
+ flags: ['target', 'skill-version-id', 'force'],
53
+ },
54
+ {
55
+ name: 'update',
56
+ summary: 'Update installed skills to their latest versions',
57
+ usage: ['update [skillId|skillName] [--target <directory>] [--dry-run] [--force]'],
58
+ description: 'Removes files that the new version no longer contains. Installs with local changes are skipped unless you pass --force.',
59
+ options: [
60
+ ['--target <directory>', 'Only update the install in this directory'],
61
+ ['--dry-run', 'Print planned changes without writing files'],
62
+ ['--force', 'Overwrite local changes'],
63
+ ],
64
+ examples: ['skillshare update', 'skillshare update "Prompt Lint" --dry-run'],
65
+ flags: ['target', 'dry-run', 'force'],
66
+ },
67
+ {
68
+ name: 'upload',
69
+ summary: 'Create a skill or publish a new version from a directory',
70
+ usage: ['upload <directory> --version <version> [--skill-id <id>] [--name <name>] [--description <text>]'],
71
+ options: [
72
+ ['--version <version>', 'Version to publish'],
73
+ ['--skill-id <id>', 'Publish to this existing skill'],
74
+ ['--name <name>', 'Name for a new skill (defaults to SKILL.md)'],
75
+ ['--description <text>', 'Description for a new skill (defaults to SKILL.md)'],
76
+ ],
77
+ examples: ['skillshare upload ./prompt-lint --version 0.2.0'],
78
+ flags: ['version', 'skill-id', 'name', 'description'],
79
+ },
80
+ {
81
+ name: 'pages',
82
+ summary: 'Publish and inspect authenticated pages',
83
+ usage: [
84
+ 'pages list',
85
+ 'pages show <slug>',
86
+ 'pages runtime <slug>',
87
+ 'pages upload <directory> [--slug <slug>]',
88
+ 'pages dev [directory] [--slug <slug>] [--port <port>] [--build <command> --watch-dir <sources>]',
89
+ ' [--celld <executable>] [--user-id <id>] [--user-name <name>] [--user-email <email>]',
90
+ ],
91
+ examples: ['skillshare pages upload ./site --slug release-notes', 'skillshare pages dev . --slug my-app'],
92
+ flags: ['slug'],
93
+ },
94
+ {
95
+ name: 'teams',
96
+ summary: 'Connect Microsoft Teams to the Skillshare Teams MCP',
97
+ usage: [
98
+ 'teams login [local] [--browser chrome|msedge|chromium] [--headless] [--browser-profile <directory>]',
99
+ 'teams watch [local] [--browser chrome|msedge|chromium]',
100
+ 'teams status | test | logout',
101
+ 'teams access readOnly|fullAccess',
102
+ 'teams upload <file>',
103
+ 'teams download <url> --output <new-file>',
104
+ ],
105
+ flags: ['browser', 'headless', 'browser-profile', 'output'],
106
+ },
107
+ {
108
+ name: 'git',
109
+ summary: 'Register Skillshare in Git Credential Manager',
110
+ usage: ['git configure [local]', 'git unconfigure [local]'],
111
+ flags: [],
112
+ },
113
+ ];
114
+ const COMMAND_ALIASES = { page: 'pages' };
115
+ export function findCommand(name) {
116
+ if (!name)
117
+ return undefined;
118
+ const resolved = COMMAND_ALIASES[name] ?? name;
119
+ return COMMANDS.find((command) => command.name === resolved);
120
+ }
121
+ export function renderUsage(defaultUrl) {
122
+ const width = Math.max(...COMMANDS.map((command) => command.name.length));
123
+ return [
124
+ 'Usage: skillshare [command] [options]',
125
+ '',
126
+ 'Run without a command for the interactive mode.',
127
+ '',
128
+ 'Commands:',
129
+ ...COMMANDS.map((command) => ` ${command.name.padEnd(width)} ${command.summary}`),
130
+ '',
131
+ 'Options:',
132
+ ' --token <token> Bearer token for API requests (overrides SKILLSHARE_TOKEN)',
133
+ ' --server <url> Skillshare origin for login and Teams commands',
134
+ ' -h, --help Show help for a command',
135
+ ' -v, --version Show the CLI version',
136
+ '',
137
+ `Default server: ${defaultUrl}`,
138
+ 'A stored session keeps its server; run "skillshare login" to switch to the default.',
139
+ 'Run "skillshare <command> --help" for details.',
140
+ ].join('\n');
141
+ }
142
+ export function renderCommandHelp(command) {
143
+ const lines = [`${command.summary}.`, '', 'Usage:', ...command.usage.map((usage) => ` skillshare ${usage}`)];
144
+ if (command.description)
145
+ lines.push('', command.description);
146
+ if (command.options?.length) {
147
+ const width = Math.max(...command.options.map(([flag]) => flag.length));
148
+ lines.push('', 'Options:', ...command.options.map(([flag, text]) => ` ${flag.padEnd(width)} ${text}`));
149
+ }
150
+ if (command.examples?.length)
151
+ lines.push('', 'Examples:', ...command.examples.map((example) => ` ${example}`));
152
+ return lines.join('\n');
153
+ }
154
+ export function readCliVersion() {
155
+ // dist/index.js and the source index.ts sit at different depths below package.json.
156
+ for (const candidate of ['../package.json', './package.json']) {
157
+ try {
158
+ const parsed = JSON.parse(readFileSync(new URL(candidate, import.meta.url), 'utf8'));
159
+ if (parsed.name === '@quadient/skillshare' && parsed.version)
160
+ return parsed.version;
161
+ }
162
+ catch {
163
+ // Try the next location.
164
+ }
165
+ }
166
+ return 'unknown';
167
+ }
package/dist/index.js CHANGED
@@ -1,4 +1,7 @@
1
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';
2
5
  import { preparePageFiles } from './pages.js';
3
6
  import { runPageDev } from './pages-dev.js';
4
7
  import { createServer } from 'node:http';
@@ -6,9 +9,9 @@ import { createHash, randomBytes } from 'node:crypto';
6
9
  import { spawn } from 'node:child_process';
7
10
  import { existsSync } from 'node:fs';
8
11
  import { mkdir, open, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
9
- import { basename, dirname, join, relative, resolve } from 'node:path';
12
+ import { basename, join, relative, resolve } from 'node:path';
10
13
  import { homedir } from 'node:os';
11
- 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';
12
15
  const DEFAULT_URL = 'https://skillshare.quadientcloudpreprod.eu';
13
16
  // Records without a registry URL were created against UAT before the preprod migration.
14
17
  const LEGACY_UAT_URL = 'https://skillshare.inspireclouduat.net';
@@ -68,45 +71,7 @@ const AGENTS = [
68
71
  { value: 'pochi', label: 'Pochi', projectPath: '.pochi/skills', globalPath: '.pochi/skills' },
69
72
  { value: 'adal', label: 'AdaL', projectPath: '.adal/skills', globalPath: '.adal/skills' },
70
73
  ];
71
- const usage = `
72
- skillshare <command> [options]
73
-
74
- Commands:
75
- login [local]
76
- logout
77
- whoami
78
- list
79
- show <skillId> [version]
80
- upload <directory> --version <version> [--skill-id <id>] [--name <name> --description <desc>]
81
- install
82
- install <skillId|skillName> [version]
83
- install <skillId|skillName> [version] --target <directory>
84
- install --skill-version-id <id> --target <directory>
85
- update [skillId|skillName] [--target <directory>] [--dry-run]
86
- pages list
87
- pages show <slug>
88
- pages upload <directory> [--slug <slug>]
89
- pages dev [directory] [--slug <slug>] [--port <port>]
90
- [--build <command> --watch-dir <sources>] [--celld <executable>]
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>
100
- git configure [local]
101
- git unconfigure [local]
102
-
103
- Options:
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.
109
- `;
74
+ const LOGIN_TIMEOUT_MS = 5 * 60_000;
110
75
  async function main() {
111
76
  const args = process.argv.slice(2);
112
77
  const command = args[0];
@@ -114,11 +79,29 @@ async function main() {
114
79
  await runInteractive();
115
80
  return;
116
81
  }
117
- if (command === '--help' || command === '-h') {
118
- console.log(usage.trim());
119
- 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;
120
104
  }
121
- const options = await parseOptions(args.slice(1));
122
105
  if ((command === 'pages' || command === 'page') && options.positionals[0] === 'dev') {
123
106
  await runPageDev(options.positionals[1] ?? '.', options.flags);
124
107
  return;
@@ -223,6 +206,7 @@ async function main() {
223
206
  const skillRef = options.positionals[0];
224
207
  const version = options.positionals[1];
225
208
  const target = options.flags.target;
209
+ const force = isFlagEnabled(options.flags, 'force');
226
210
  if (skillVersionId) {
227
211
  if (!target) {
228
212
  throw new Error('install with --skill-version-id requires --target <directory>');
@@ -233,6 +217,7 @@ async function main() {
233
217
  }
234
218
  const detail = await client.get(`/api/skill-versions/${parsedSkillVersionId}`);
235
219
  const skill = await client.get(`/api/skills/${detail.skillId}`);
220
+ await ensureTargetsWritable(client.baseUrl, [target], { force });
236
221
  await client.post(`/api/skills/${detail.skillId}/versions/${detail.version}/downloads`);
237
222
  const installProgress = createInstallProgressReporter({
238
223
  skillName: skill.name,
@@ -262,6 +247,7 @@ async function main() {
262
247
  await runInteractiveInstall(client, {
263
248
  skillRef,
264
249
  version,
250
+ force,
265
251
  });
266
252
  return;
267
253
  }
@@ -275,6 +261,7 @@ async function main() {
275
261
  throw new Error(`Skill '${skill.name}' does not have a published version.`);
276
262
  }
277
263
  const detail = await client.get(`/api/skills/${skill.id}/versions/${effectiveVersion}`);
264
+ await ensureTargetsWritable(client.baseUrl, [target], { force });
278
265
  await client.post(`/api/skills/${skill.id}/versions/${effectiveVersion}/downloads`);
279
266
  const installProgress = createInstallProgressReporter({
280
267
  skillName: skill.name,
@@ -305,6 +292,7 @@ async function main() {
305
292
  skillRef: options.positionals[0],
306
293
  target: options.flags.target,
307
294
  dryRun: isFlagEnabled(options.flags, 'dry-run'),
295
+ force: isFlagEnabled(options.flags, 'force'),
308
296
  });
309
297
  return;
310
298
  }
@@ -351,23 +339,22 @@ async function main() {
351
339
  return;
352
340
  }
353
341
  default:
354
- throw new Error(`Unknown command '${command}'`);
342
+ throw new Error(formatUnknownCommand(command));
355
343
  }
356
344
  }
357
345
  async function runInteractive() {
358
- intro('Skillshare');
346
+ intro(`Skillshare ${readCliVersion()}`);
359
347
  const baseUrl = await resolveActiveBaseUrl();
360
- await ensureInteractiveLogin(baseUrl);
348
+ const user = await ensureInteractiveLogin(baseUrl);
361
349
  const client = await createClient(baseUrl);
362
- 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);
363
352
  if (pendingInstalledUpdates.length > 0) {
364
353
  const shouldUpdateInstalled = await promptConfirm(pendingInstalledUpdates.length === 1
365
354
  ? `There is 1 installed skill with a newer version available. Update it now?`
366
355
  : `There are ${pendingInstalledUpdates.length} installed skills with newer versions available. Update all of them now?`, true);
367
356
  if (shouldUpdateInstalled) {
368
357
  await updateInstalledSkills(client, { candidates: pendingInstalledUpdates });
369
- outro('Done.');
370
- return;
371
358
  }
372
359
  }
373
360
  if (existsSync(join(process.cwd(), SKILL_FILE_NAME))) {
@@ -387,12 +374,17 @@ async function runInteractive() {
387
374
  { value: 'install', label: 'Install a skill', hint: 'Browse registry and install to agents' },
388
375
  { value: 'update', label: 'Update installed skills', hint: 'Refresh recorded installs to the latest registry versions' },
389
376
  { value: 'upload', label: 'Upload a skill', hint: 'Create a new skill or publish a version' },
377
+ { value: 'exit', label: 'Exit' },
390
378
  ],
391
379
  });
392
380
  if (isCancel(action)) {
393
381
  cancel('Cancelled.');
394
382
  process.exit(0);
395
383
  }
384
+ if (action === 'exit') {
385
+ outro('Bye.');
386
+ return;
387
+ }
396
388
  if (action === 'install') {
397
389
  await runInteractiveInstall(client);
398
390
  }
@@ -404,22 +396,53 @@ async function runInteractive() {
404
396
  }
405
397
  outro('Done.');
406
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
+ }
407
431
  async function ensureInteractiveLogin(baseUrl) {
408
432
  const session = await loadSession(baseUrl);
409
- if (!session) {
410
- await login(baseUrl);
411
- return;
412
- }
413
- if (new Date(session.refreshTokenExpiresAt).getTime() <= Date.now()) {
414
- await login(baseUrl);
415
- return;
433
+ if (!session || new Date(session.refreshTokenExpiresAt).getTime() <= Date.now()) {
434
+ return login(baseUrl);
416
435
  }
417
436
  try {
418
437
  const client = await createClient(baseUrl);
419
- await client.get('/api/auth/me');
438
+ return await client.get('/api/auth/me');
420
439
  }
421
- catch {
422
- 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);
423
446
  }
424
447
  }
425
448
  async function runInteractiveInstall(client, options) {
@@ -440,9 +463,13 @@ async function runInteractiveInstall(client, options) {
440
463
  throw new Error('Select at least one agent.');
441
464
  }
442
465
  const versionDetail = await client.get(`/api/skills/${skill.id}/versions/${selectedVersion}`);
443
- await client.post(`/api/skills/${skill.id}/versions/${selectedVersion}/downloads`);
444
466
  const installName = createInstalledSkillDirectoryName(skill);
445
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`);
446
473
  const installProgress = createInstallProgressReporter({
447
474
  skillName: skill.name,
448
475
  version: selectedVersion,
@@ -466,7 +493,11 @@ async function runInteractiveInstall(client, options) {
466
493
  installProgress.clear();
467
494
  throw error;
468
495
  }
469
- 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'));
470
501
  }
471
502
  async function runInteractiveUpload(client, initialDirectory) {
472
503
  const directory = initialDirectory
@@ -524,6 +555,7 @@ async function updateInstalledSkills(client, options) {
524
555
  const targetFilter = normalizeText(options?.target);
525
556
  const normalizedTargetFilter = targetFilter ? resolve(targetFilter) : null;
526
557
  const dryRun = options?.dryRun ?? false;
558
+ const force = options?.force ?? false;
527
559
  let installedRecords = (await readInstalledSkillRecords())
528
560
  .filter((record) => record.baseUrl === client.baseUrl);
529
561
  if (skillRef) {
@@ -547,11 +579,11 @@ async function updateInstalledSkills(client, options) {
547
579
  const candidates = options?.candidates
548
580
  ?? await collectInstalledSkillUpdateCandidates(client, {
549
581
  records: installedRecords,
550
- onSkipped(message) {
551
- console.log(message);
582
+ onSkipped(skip) {
583
+ console.log(skip.message);
552
584
  },
553
585
  });
554
- const skippedCount = installedRecords.length - candidates.length;
586
+ let skippedCount = installedRecords.length - candidates.length;
555
587
  if (candidates.length === 0) {
556
588
  if (dryRun) {
557
589
  console.log(`Dry run finished. Would update 0, skipped ${skippedCount}.`);
@@ -565,9 +597,16 @@ async function updateInstalledSkills(client, options) {
565
597
  let plannedCount = 0;
566
598
  for (const candidate of candidates) {
567
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
+ }
568
607
  if (dryRun) {
569
608
  plannedCount += 1;
570
- 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)}`);
571
610
  continue;
572
611
  }
573
612
  const versionDetail = await client.get(`/api/skills/${record.skillId}/versions/${latestVersion}`);
@@ -594,7 +633,7 @@ async function updateInstalledSkills(client, options) {
594
633
  throw error;
595
634
  }
596
635
  updatedCount += 1;
597
- 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)}`);
598
637
  }
599
638
  if (dryRun) {
600
639
  console.log(`Dry run finished. Would update ${plannedCount}, skipped ${skippedCount}.`);
@@ -605,30 +644,59 @@ async function updateInstalledSkills(client, options) {
605
644
  async function collectInstalledSkillUpdateCandidates(client, options) {
606
645
  const records = options?.records
607
646
  ?? (await readInstalledSkillRecords()).filter((record) => record.baseUrl === client.baseUrl);
608
- const candidates = [];
609
- for (const record of records) {
610
- if (record.skillId == null) {
611
- options?.onSkipped?.(`Skipping ${record.skillName}: installed record in ${record.targetRoot} does not contain a skill id.`);
612
- 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)}` };
613
666
  }
614
- const detail = await client.get(`/api/skills/${record.skillId}`);
615
667
  const latestVersion = normalizeText(detail.latestVersion);
616
668
  if (!latestVersion) {
617
- options?.onSkipped?.(`Skipping ${record.skillName}: no latest version is published.`);
618
- continue;
669
+ return { record, reason: 'no-version', message: `Skipping ${record.skillName}: no latest version is published.` };
619
670
  }
620
671
  if (latestVersion === normalizeText(record.skillVersion)) {
621
- options?.onSkipped?.(`Skipping ${record.skillName}: ${latestVersion} is already installed in ${record.targetRoot}.`);
622
- 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);
623
683
  }
624
- candidates.push({
625
- record,
626
- detail,
627
- latestVersion,
628
- });
629
684
  }
630
685
  return candidates;
631
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
+ }
632
700
  function formatUpdateSelectionDescription(options) {
633
701
  const parts = [];
634
702
  if (options.skillRef) {
@@ -749,7 +817,7 @@ async function promptForAgents(scope) {
749
817
  })),
750
818
  ],
751
819
  initialValues,
752
- required: false,
820
+ required: true,
753
821
  });
754
822
  if (isCancel(selected)) {
755
823
  cancel('Cancelled.');
@@ -885,31 +953,81 @@ async function publishSkillFromDirectory(client, localSkill, input) {
885
953
  }
886
954
  async function writeSkillVersionToDirectory(client, input, installProgress) {
887
955
  installProgress?.onTargetStart(input.targetRoot);
888
- for (const file of input.files) {
889
- installProgress?.onFileStart(input.targetRoot, file.nameWithPath, file.length);
890
- const response = await client.rawGet(`/api/skills/${input.skillId}/versions/${input.version}/files/${file.nameWithPath}`);
891
- const destination = join(input.targetRoot, file.nameWithPath);
892
- await mkdir(dirname(destination), { recursive: true });
893
- await downloadResponseToFile(response, destination, (bytes) => {
894
- installProgress?.onBytesTransferred(bytes);
895
- });
896
- installProgress?.onFileComplete();
897
- }
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
+ });
898
972
  await upsertInstalledSkillRecord(client.baseUrl, input.targetRoot, {
899
973
  skillId: input.skillId,
900
974
  skillName: input.skillName,
901
975
  skillDescription: input.skillDescription,
902
976
  skillVersion: input.version,
903
- });
977
+ }, manifest);
904
978
  installProgress?.onTargetComplete();
905
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
+ }
906
1023
  async function downloadResponseToFile(response, destination, onBytesTransferred) {
1024
+ const hash = createHash('sha256');
907
1025
  const body = response.body;
908
1026
  if (!body) {
909
1027
  const buffer = Buffer.from(await response.arrayBuffer());
910
1028
  await writeFile(destination, buffer);
911
1029
  onBytesTransferred?.(buffer.length);
912
- return;
1030
+ return hash.update(buffer).digest('hex');
913
1031
  }
914
1032
  const fileHandle = await open(destination, 'w');
915
1033
  let failed = true;
@@ -925,6 +1043,7 @@ async function downloadResponseToFile(response, destination, onBytesTransferred)
925
1043
  continue;
926
1044
  }
927
1045
  await fileHandle.writeFile(value);
1046
+ hash.update(value);
928
1047
  onBytesTransferred?.(value.length);
929
1048
  }
930
1049
  }
@@ -934,6 +1053,7 @@ async function downloadResponseToFile(response, destination, onBytesTransferred)
934
1053
  await rm(destination, { force: true });
935
1054
  }
936
1055
  }
1056
+ return hash.digest('hex');
937
1057
  }
938
1058
  function createInstallProgressReporter(input) {
939
1059
  const installStatus = spinner({ indicator: 'timer' });
@@ -1349,9 +1469,15 @@ async function login(baseUrl) {
1349
1469
  const loginUrl = `${baseUrl}/api/cli/login?redirectUri=${encodeURIComponent(callback.redirectUri)}` +
1350
1470
  `&codeChallenge=${encodeURIComponent(codeChallenge)}` +
1351
1471
  `&state=${encodeURIComponent(state)}`;
1352
- await openBrowser(loginUrl);
1353
- console.log(`Opening browser for ${baseUrl}`);
1354
- 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);
1355
1481
  const response = await fetchJson(`${baseUrl}/api/cli/token`, {
1356
1482
  method: 'POST',
1357
1483
  headers: {
@@ -1373,6 +1499,7 @@ async function login(baseUrl) {
1373
1499
  user: response.user,
1374
1500
  });
1375
1501
  console.log(`Logged in as ${response.user.displayName} (${baseUrl})`);
1502
+ return response.user;
1376
1503
  }
1377
1504
  const GCM_MINIMUM_VERSION = [2, 0, 931];
1378
1505
  async function configureGitCredentialManager(baseUrl) {
@@ -1434,36 +1561,8 @@ function compareVersions(left, right) {
1434
1561
  function formatVersion(version) {
1435
1562
  return version.join('.');
1436
1563
  }
1437
- async function parseOptions(args) {
1438
- const booleanFlags = new Set(['dry-run', 'headless']);
1439
- const flags = {};
1440
- const positionals = [];
1441
- for (let index = 0; index < args.length; index += 1) {
1442
- const current = args[index];
1443
- if (!current) {
1444
- continue;
1445
- }
1446
- if (current.startsWith('--')) {
1447
- const key = current.slice(2);
1448
- const value = args[index + 1];
1449
- if (key === 'headless' && value !== 'true' && value !== 'false') {
1450
- flags[key] = 'true';
1451
- continue;
1452
- }
1453
- if (!value || value.startsWith('--')) {
1454
- if (booleanFlags.has(key)) {
1455
- flags[key] = 'true';
1456
- continue;
1457
- }
1458
- throw new Error(`Missing value for ${current}`);
1459
- }
1460
- flags[key] = value;
1461
- index += 1;
1462
- }
1463
- else {
1464
- positionals.push(current);
1465
- }
1466
- }
1564
+ async function parseOptions(args, commandName) {
1565
+ const { flags, positionals } = parseCliArgs(args, commandName);
1467
1566
  const tokenFromFlag = normalizeText(flags.token);
1468
1567
  const tokenFromEnvironment = normalizeText(process.env.SKILLSHARE_TOKEN);
1469
1568
  return {
@@ -1500,7 +1599,7 @@ async function createClient(baseUrl, explicitToken) {
1500
1599
  if (new Date(currentSession.refreshTokenExpiresAt).getTime() <= Date.now()) {
1501
1600
  throw new Error(`Stored session for ${resolvedBaseUrl} is no longer valid. Run "skillshare login${resolvedBaseUrl === LOCAL_URL ? ' local' : ''}" again.`);
1502
1601
  }
1503
- const response = await fetch(`${resolvedBaseUrl}/api/cli/refresh`, {
1602
+ const response = await fetchFromServer(`${resolvedBaseUrl}/api/cli/refresh`, {
1504
1603
  method: 'POST',
1505
1604
  headers: {
1506
1605
  'Content-Type': 'application/json',
@@ -1537,7 +1636,7 @@ async function createClient(baseUrl, explicitToken) {
1537
1636
  return refreshedSession.accessToken;
1538
1637
  }
1539
1638
  async function sendAuthorized(path, init, retryUnauthorized = true) {
1540
- const response = await fetch(`${resolvedBaseUrl}${path}`, {
1639
+ const response = await fetchFromServer(`${resolvedBaseUrl}${path}`, {
1541
1640
  ...init,
1542
1641
  headers: await buildHeaders(() => getAccessToken(), init?.headers),
1543
1642
  });
@@ -1545,7 +1644,7 @@ async function createClient(baseUrl, explicitToken) {
1545
1644
  return response;
1546
1645
  }
1547
1646
  const refreshedToken = await getAccessToken({ forceRefresh: true });
1548
- return fetch(`${resolvedBaseUrl}${path}`, {
1647
+ return fetchFromServer(`${resolvedBaseUrl}${path}`, {
1549
1648
  ...init,
1550
1649
  headers: await buildHeaders(() => Promise.resolve(refreshedToken), init?.headers),
1551
1650
  });
@@ -1569,7 +1668,7 @@ async function createClient(baseUrl, explicitToken) {
1569
1668
  async rawGet(path) {
1570
1669
  const response = await sendAuthorized(path);
1571
1670
  if (!response.ok) {
1572
- throw new Error(await formatErrorResponse(response));
1671
+ throw new HttpError(response.status, await formatErrorResponse(response));
1573
1672
  }
1574
1673
  return response;
1575
1674
  },
@@ -1624,9 +1723,34 @@ async function buildHeaders(getAccessToken, initialHeaders) {
1624
1723
  headers.set('Authorization', `Bearer ${await getAccessToken()}`);
1625
1724
  return headers;
1626
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
+ }
1627
1751
  async function parseJson(response) {
1628
1752
  if (!response.ok) {
1629
- throw new Error(await formatErrorResponse(response));
1753
+ throw new HttpError(response.status, await formatErrorResponse(response));
1630
1754
  }
1631
1755
  return response.json();
1632
1756
  }
@@ -1661,7 +1785,7 @@ async function formatErrorResponse(response) {
1661
1785
  : `${fallbackMessage} ${body}`;
1662
1786
  }
1663
1787
  async function fetchJson(input, init) {
1664
- const response = await fetch(input, init);
1788
+ const response = await fetchFromServer(input, init);
1665
1789
  return parseJson(response);
1666
1790
  }
1667
1791
  async function loadSession(baseUrl) {
@@ -1767,15 +1891,22 @@ async function waitForLoginCallback() {
1767
1891
  }
1768
1892
  return {
1769
1893
  redirectUri: `http://127.0.0.1:${address.port}/callback`,
1770
- 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
+ });
1771
1901
  try {
1772
- const payload = JSON.parse(await statePromise);
1902
+ const payload = JSON.parse(await Promise.race([statePromise, timeout]));
1773
1903
  if (payload.state !== expectedState) {
1774
1904
  throw new Error('Login state mismatch.');
1775
1905
  }
1776
1906
  return payload.code;
1777
1907
  }
1778
1908
  finally {
1909
+ clearTimeout(timer);
1779
1910
  server.close();
1780
1911
  }
1781
1912
  },
@@ -1832,7 +1963,7 @@ async function upsertUploadedSkillMetadata(baseUrl, directory, metadata) {
1832
1963
  await writeStateFile(UPLOADED_STATE_PATH, records);
1833
1964
  await removeLegacyLocalSkillMetadata(resolvedDirectory);
1834
1965
  }
1835
- async function upsertInstalledSkillRecord(baseUrl, targetRoot, metadata) {
1966
+ async function upsertInstalledSkillRecord(baseUrl, targetRoot, metadata, files) {
1836
1967
  const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
1837
1968
  const resolvedTargetRoot = resolve(targetRoot);
1838
1969
  const records = (await readInstalledSkillRecords())
@@ -1842,6 +1973,7 @@ async function upsertInstalledSkillRecord(baseUrl, targetRoot, metadata) {
1842
1973
  targetRoot: resolvedTargetRoot,
1843
1974
  baseUrl: normalizedBaseUrl,
1844
1975
  updatedAt: new Date().toISOString(),
1976
+ ...(files ? { files } : {}),
1845
1977
  });
1846
1978
  await writeStateFile(INSTALLED_STATE_PATH, records);
1847
1979
  await removeLegacyLocalSkillMetadata(resolvedTargetRoot);
@@ -1883,9 +2015,17 @@ async function readInstalledSkillRecords() {
1883
2015
  skillName: typeof entry.skillName === 'string' ? entry.skillName : '',
1884
2016
  skillDescription: typeof entry.skillDescription === 'string' ? entry.skillDescription : '',
1885
2017
  skillVersion: typeof entry.skillVersion === 'string' ? entry.skillVersion : '',
2018
+ files: readFileManifest(entry.files),
1886
2019
  }].filter((record) => record.targetRoot.length > 0);
1887
2020
  });
1888
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
+ }
1889
2029
  async function readStateFile(path) {
1890
2030
  try {
1891
2031
  const parsed = JSON.parse(await readFile(path, 'utf8'));
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quadient/skillshare",
3
- "version": "1.7.0",
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 teams-auth.test.ts teams-watch.test.ts teams-browser.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"