@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 +8 -0
- package/dist/cli-args.js +86 -0
- package/dist/help.js +167 -0
- package/dist/index.js +278 -138
- package/dist/install-files.js +111 -0
- package/package.json +2 -2
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.
|
package/dist/cli-args.js
ADDED
|
@@ -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,
|
|
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
|
|
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 === '--
|
|
118
|
-
console.log(
|
|
119
|
-
|
|
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(
|
|
342
|
+
throw new Error(formatUnknownCommand(command));
|
|
355
343
|
}
|
|
356
344
|
}
|
|
357
345
|
async function runInteractive() {
|
|
358
|
-
intro(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
551
|
-
console.log(message);
|
|
582
|
+
onSkipped(skip) {
|
|
583
|
+
console.log(skip.message);
|
|
552
584
|
},
|
|
553
585
|
});
|
|
554
|
-
|
|
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
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
|
|
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
|
-
|
|
622
|
-
|
|
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:
|
|
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
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
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
|
-
|
|
1353
|
-
console.log(`
|
|
1354
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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"
|