happyskills 0.2.0 → 0.3.1
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/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/src/commands/convert.js +4 -3
- package/src/commands/list.js +2 -2
- package/src/commands/self-update.js +83 -0
- package/src/commands/setup.js +55 -0
- package/src/commands/update.js +4 -3
- package/src/config/paths.js +5 -0
- package/src/constants.js +3 -1
- package/src/engine/installer.js +4 -3
- package/src/engine/uninstaller.js +4 -3
- package/src/index.js +14 -1
- package/src/integration/cli.test.js +52 -3
- package/src/utils/update_check.js +56 -0
- package/src/utils/update_check.test.js +110 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.1] - 2026-03-04
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- Fix global installs writing `skills-lock.json` to the local project directory instead of `~/.claude/`; global lock state is now read/written from `~/.claude/skills-lock.json` across `install`, `uninstall`, `list`, `update`, and `convert` commands
|
|
14
|
+
|
|
15
|
+
## [0.3.0] - 2026-03-04
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- Add `setup` command to install the official `happyskillsai/happyskills-cli` Claude Code skill globally (`~/.claude/skills/`); idempotent — reports "already up to date" if already at the latest version
|
|
19
|
+
- Add `self-update` command to upgrade the `happyskills` CLI itself to the latest published npm version via `npm install -g happyskills@latest`
|
|
20
|
+
- Add passive update-check notification — once per day the CLI silently checks npm for a newer version and prints a warning to stderr before the command runs; suppressed in `--json` mode and when running `self-update`
|
|
21
|
+
|
|
10
22
|
## [0.2.0] - 2026-03-04
|
|
11
23
|
|
|
12
24
|
### Added
|
package/package.json
CHANGED
package/src/commands/convert.js
CHANGED
|
@@ -12,7 +12,7 @@ const { write_manifest } = require('../manifest/writer')
|
|
|
12
12
|
const { read_lock } = require('../lock/reader')
|
|
13
13
|
const { write_lock, update_lock_skills } = require('../lock/writer')
|
|
14
14
|
const { hash_directory } = require('../lock/integrity')
|
|
15
|
-
const { skills_dir, find_project_root } = require('../config/paths')
|
|
15
|
+
const { skills_dir, find_project_root, lock_root } = require('../config/paths')
|
|
16
16
|
const { file_exists } = require('../utils/fs')
|
|
17
17
|
const { create_spinner } = require('../ui/spinner')
|
|
18
18
|
const { print_help, print_success, print_error, print_info, print_label, print_json } = require('../ui/output')
|
|
@@ -154,7 +154,8 @@ const run = (args) => catch_errors('Convert failed', async () => {
|
|
|
154
154
|
if (push_err) { pub_spinner.fail('Publish failed'); throw e('Push failed', push_err) }
|
|
155
155
|
|
|
156
156
|
pub_spinner.update('Updating lock file...')
|
|
157
|
-
const
|
|
157
|
+
const lock_dir = lock_root(is_global, project_root)
|
|
158
|
+
const [, lock_data] = await read_lock(lock_dir)
|
|
158
159
|
const [, integrity] = await hash_directory(skill_dir)
|
|
159
160
|
|
|
160
161
|
const full_name = `${workspace.slug}/${skill_name}`
|
|
@@ -170,7 +171,7 @@ const run = (args) => catch_errors('Convert failed', async () => {
|
|
|
170
171
|
}
|
|
171
172
|
|
|
172
173
|
const new_skills = update_lock_skills(lock_data, updates)
|
|
173
|
-
const [lock_err] = await write_lock(
|
|
174
|
+
const [lock_err] = await write_lock(lock_dir, new_skills)
|
|
174
175
|
if (lock_err) { pub_spinner.fail('Failed to write lock file'); throw e('Lock write failed', lock_err) }
|
|
175
176
|
|
|
176
177
|
pub_spinner.succeed(`Converted ${full_name}@${version}`)
|
package/src/commands/list.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
2
2
|
const { read_lock, get_all_locked_skills } = require('../lock/reader')
|
|
3
|
-
const { skills_dir, skill_install_dir, find_project_root } = require('../config/paths')
|
|
3
|
+
const { skills_dir, skill_install_dir, find_project_root, lock_root } = require('../config/paths')
|
|
4
4
|
const { file_exists } = require('../utils/fs')
|
|
5
5
|
const { scan_skills_dir } = require('../utils/skill_scanner')
|
|
6
6
|
const { print_help, print_table, print_json, print_info } = require('../ui/output')
|
|
@@ -32,7 +32,7 @@ const run = (args) => catch_errors('List failed', async () => {
|
|
|
32
32
|
const project_root = find_project_root()
|
|
33
33
|
const base_dir = skills_dir(is_global, project_root)
|
|
34
34
|
|
|
35
|
-
const [, lock_data] = await read_lock(project_root)
|
|
35
|
+
const [, lock_data] = await read_lock(lock_root(is_global, project_root))
|
|
36
36
|
const skills = get_all_locked_skills(lock_data)
|
|
37
37
|
const managed_entries = Object.entries(skills)
|
|
38
38
|
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
2
|
+
const semver = require('semver')
|
|
3
|
+
const { CLI_VERSION, EXIT_CODES } = require('../constants')
|
|
4
|
+
const { print_success, print_info, print_help, print_hint, print_json, code } = require('../ui/output')
|
|
5
|
+
const { exit_with_error, CliError, NetworkError } = require('../utils/errors')
|
|
6
|
+
const { is_json_mode } = require('../state')
|
|
7
|
+
|
|
8
|
+
const NPM_REGISTRY_URL = process.env.HAPPYSKILLS_NPM_REGISTRY_URL || 'https://registry.npmjs.org/happyskills/latest'
|
|
9
|
+
|
|
10
|
+
const HELP_TEXT = `Usage: happyskills self-update
|
|
11
|
+
|
|
12
|
+
Upgrade the happyskills CLI to the latest version.
|
|
13
|
+
|
|
14
|
+
Fetches the latest version from npm and runs:
|
|
15
|
+
npm install -g happyskills@latest
|
|
16
|
+
|
|
17
|
+
Options:
|
|
18
|
+
--json Output as JSON
|
|
19
|
+
|
|
20
|
+
Examples:
|
|
21
|
+
happyskills self-update`
|
|
22
|
+
|
|
23
|
+
const get_latest_version = () => catch_errors('Failed to fetch latest version', async () => {
|
|
24
|
+
let res
|
|
25
|
+
try {
|
|
26
|
+
res = await fetch(NPM_REGISTRY_URL, { signal: AbortSignal.timeout(10000) })
|
|
27
|
+
} catch {
|
|
28
|
+
throw new NetworkError('Could not reach npm registry. Check your internet connection.')
|
|
29
|
+
}
|
|
30
|
+
if (!res.ok) throw new CliError(`npm registry returned ${res.status}`)
|
|
31
|
+
const data = await res.json()
|
|
32
|
+
if (!data || !data.version) throw new CliError('Could not parse version from npm registry')
|
|
33
|
+
return data.version
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const run_npm_install = () => catch_errors('npm install failed', async () => {
|
|
37
|
+
const { spawn } = require('child_process')
|
|
38
|
+
const stdio = is_json_mode() ? 'pipe' : 'inherit'
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const child = spawn('npm', ['install', '-g', 'happyskills@latest'], { stdio })
|
|
41
|
+
child.on('close', (exit_code) => {
|
|
42
|
+
if (exit_code !== 0) reject(new CliError(`npm install exited with code ${exit_code}`))
|
|
43
|
+
else resolve()
|
|
44
|
+
})
|
|
45
|
+
child.on('error', (err) => reject(err))
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const run = (args) => catch_errors('Self-update failed', async () => {
|
|
50
|
+
if (args.flags._show_help) {
|
|
51
|
+
print_help(HELP_TEXT)
|
|
52
|
+
return process.exit(EXIT_CODES.SUCCESS)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const [ver_err, latest] = await get_latest_version()
|
|
56
|
+
if (ver_err) throw e('Failed to check for updates', ver_err)
|
|
57
|
+
|
|
58
|
+
if (!semver.gt(latest, CLI_VERSION)) {
|
|
59
|
+
if (args.flags.json) {
|
|
60
|
+
print_json({ data: { status: 'already_up_to_date', version: CLI_VERSION } })
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
print_info(`happyskills is already up to date (v${CLI_VERSION})`)
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!args.flags.json) {
|
|
68
|
+
print_info(`Updating happyskills v${CLI_VERSION} → v${latest}...`)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const [install_err] = await run_npm_install()
|
|
72
|
+
if (install_err) throw e('Update failed', install_err)
|
|
73
|
+
|
|
74
|
+
if (args.flags.json) {
|
|
75
|
+
print_json({ data: { status: 'updated', from: CLI_VERSION, to: latest } })
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
print_success(`happyskills updated to v${latest}`)
|
|
80
|
+
print_hint(`Run ${code('happyskills --version')} to confirm.`)
|
|
81
|
+
}).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
|
|
82
|
+
|
|
83
|
+
module.exports = { run }
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
2
|
+
const { install } = require('../engine/installer')
|
|
3
|
+
const { find_project_root } = require('../config/paths')
|
|
4
|
+
const { print_success, print_info, print_help, print_hint, print_json, code } = require('../ui/output')
|
|
5
|
+
const { exit_with_error } = require('../utils/errors')
|
|
6
|
+
const { EXIT_CODES } = require('../constants')
|
|
7
|
+
|
|
8
|
+
const SKILL_NAME = 'happyskillsai/happyskills-cli'
|
|
9
|
+
|
|
10
|
+
const HELP_TEXT = `Usage: happyskills setup
|
|
11
|
+
|
|
12
|
+
Install (or update) the official HappySkills CLI skill globally.
|
|
13
|
+
|
|
14
|
+
Installs happyskillsai/happyskills-cli into ~/.claude/skills/ so AI agents
|
|
15
|
+
(Claude Code, etc.) can interact with HappySkills using natural language.
|
|
16
|
+
|
|
17
|
+
Options:
|
|
18
|
+
--json Output as JSON
|
|
19
|
+
|
|
20
|
+
Examples:
|
|
21
|
+
happyskills setup`
|
|
22
|
+
|
|
23
|
+
const run = (args) => catch_errors('Setup failed', async () => {
|
|
24
|
+
if (args.flags._show_help) {
|
|
25
|
+
print_help(HELP_TEXT)
|
|
26
|
+
return process.exit(EXIT_CODES.SUCCESS)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const options = {
|
|
30
|
+
global: true,
|
|
31
|
+
yes: true,
|
|
32
|
+
version: 'latest',
|
|
33
|
+
project_root: find_project_root()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const [errors, result] = await install(SKILL_NAME, options)
|
|
37
|
+
if (errors) throw e(`Failed to install ${SKILL_NAME}`, errors)
|
|
38
|
+
|
|
39
|
+
const version = result.version || ''
|
|
40
|
+
const status = (result.installed && result.installed.length > 0) ? 'installed' : 'already_up_to_date'
|
|
41
|
+
|
|
42
|
+
if (args.flags.json) {
|
|
43
|
+
print_json({ data: { skill: SKILL_NAME, version, status } })
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (status === 'already_up_to_date') {
|
|
48
|
+
print_info(`${SKILL_NAME} is already up to date${version ? ` (v${version})` : ''}`)
|
|
49
|
+
} else {
|
|
50
|
+
print_success(`${SKILL_NAME} installed${version ? ` (v${version})` : ''}`)
|
|
51
|
+
print_hint(`Restart Claude Code to activate the skill.`)
|
|
52
|
+
}
|
|
53
|
+
}).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
|
|
54
|
+
|
|
55
|
+
module.exports = { run }
|
package/src/commands/update.js
CHANGED
|
@@ -3,7 +3,7 @@ const { install } = require('../engine/installer')
|
|
|
3
3
|
const { read_lock, get_all_locked_skills } = require('../lock/reader')
|
|
4
4
|
const { print_help, print_success, print_info, print_json } = require('../ui/output')
|
|
5
5
|
const { exit_with_error, UsageError } = require('../utils/errors')
|
|
6
|
-
const { find_project_root } = require('../config/paths')
|
|
6
|
+
const { find_project_root, lock_root } = require('../config/paths')
|
|
7
7
|
const { EXIT_CODES } = require('../constants')
|
|
8
8
|
|
|
9
9
|
const HELP_TEXT = `Usage: happyskills update [owner/skill|--all] [options]
|
|
@@ -40,7 +40,8 @@ const run = (args) => catch_errors('Update failed', async () => {
|
|
|
40
40
|
throw new UsageError("Specify a skill to update or use --all (e.g., 'happyskills update acme/deploy-aws' or 'happyskills update --all').")
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
const
|
|
43
|
+
const is_global = args.flags.global || false
|
|
44
|
+
const [, lock_data] = await read_lock(lock_root(is_global, project_root))
|
|
44
45
|
const skills = get_all_locked_skills(lock_data)
|
|
45
46
|
|
|
46
47
|
const to_update = target_skill
|
|
@@ -57,7 +58,7 @@ const run = (args) => catch_errors('Update failed', async () => {
|
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
const options = {
|
|
60
|
-
global:
|
|
61
|
+
global: is_global,
|
|
61
62
|
fresh: true,
|
|
62
63
|
project_root
|
|
63
64
|
}
|
package/src/config/paths.js
CHANGED
|
@@ -26,6 +26,10 @@ const tmp_dir = (base_skills_dir) => path.join(base_skills_dir, '.tmp')
|
|
|
26
26
|
|
|
27
27
|
const install_lock_path = (base_skills_dir) => path.join(base_skills_dir, '.install.lock')
|
|
28
28
|
|
|
29
|
+
const lock_root = (is_global, project_root) => {
|
|
30
|
+
return is_global ? path.join(home_dir, '.claude') : project_root
|
|
31
|
+
}
|
|
32
|
+
|
|
29
33
|
const lock_file_path = (project_root = process.cwd()) => path.join(project_root, 'skills-lock.json')
|
|
30
34
|
|
|
31
35
|
const skill_install_dir = (base_skills_dir, name) => path.join(base_skills_dir, name)
|
|
@@ -60,6 +64,7 @@ module.exports = {
|
|
|
60
64
|
skills_dir,
|
|
61
65
|
tmp_dir,
|
|
62
66
|
install_lock_path,
|
|
67
|
+
lock_root,
|
|
63
68
|
lock_file_path,
|
|
64
69
|
skill_install_dir,
|
|
65
70
|
find_project_root
|
package/src/constants.js
CHANGED
package/src/engine/installer.js
CHANGED
|
@@ -8,7 +8,7 @@ const { check_system_dependencies } = require('./system_deps')
|
|
|
8
8
|
const { hash_directory, verify_integrity } = require('../lock/integrity')
|
|
9
9
|
const { read_lock, get_locked_skill } = require('../lock/reader')
|
|
10
10
|
const { write_lock, update_lock_skills } = require('../lock/writer')
|
|
11
|
-
const { skills_dir, tmp_dir, skill_install_dir } = require('../config/paths')
|
|
11
|
+
const { skills_dir, tmp_dir, skill_install_dir, lock_root } = require('../config/paths')
|
|
12
12
|
const { ensure_dir, remove_dir, file_exists } = require('../utils/fs')
|
|
13
13
|
const { create_spinner } = require('../ui/spinner')
|
|
14
14
|
const { print_success, print_warn } = require('../ui/output')
|
|
@@ -22,8 +22,9 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
|
|
|
22
22
|
const { version, global: is_global = false, force = false, fresh = false, project_root } = options
|
|
23
23
|
const base_dir = skills_dir(is_global, project_root)
|
|
24
24
|
const temp_dir = tmp_dir(base_dir)
|
|
25
|
+
const lock_dir = lock_root(is_global, project_root)
|
|
25
26
|
|
|
26
|
-
const [, lock_data] = fresh ? [null, null] : await read_lock(
|
|
27
|
+
const [, lock_data] = fresh ? [null, null] : await read_lock(lock_dir)
|
|
27
28
|
|
|
28
29
|
if (!fresh && lock_data) {
|
|
29
30
|
const locked = get_locked_skill(lock_data, skill)
|
|
@@ -148,7 +149,7 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
|
|
|
148
149
|
}
|
|
149
150
|
|
|
150
151
|
const new_skills = update_lock_skills(lock_data, updates)
|
|
151
|
-
const [lock_errors] = await write_lock(
|
|
152
|
+
const [lock_errors] = await write_lock(lock_dir, new_skills)
|
|
152
153
|
if (lock_errors) { spinner.fail('Failed to write lock file'); throw e('Lock write failed', lock_errors) }
|
|
153
154
|
|
|
154
155
|
spinner.succeed(`Installed ${packages_to_install.length} package(s)`)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
2
2
|
const { read_lock, get_locked_skill, get_all_locked_skills } = require('../lock/reader')
|
|
3
3
|
const { write_lock, update_lock_skills } = require('../lock/writer')
|
|
4
|
-
const { skills_dir, skill_install_dir } = require('../config/paths')
|
|
4
|
+
const { skills_dir, skill_install_dir, lock_root } = require('../config/paths')
|
|
5
5
|
const { remove_dir } = require('../utils/fs')
|
|
6
6
|
const { print_success, print_info } = require('../ui/output')
|
|
7
7
|
|
|
@@ -24,8 +24,9 @@ const find_orphans = (skills, removed_skill) => {
|
|
|
24
24
|
const uninstall = (skill, options = {}) => catch_errors('Uninstall failed', async () => {
|
|
25
25
|
const { global: is_global = false, project_root } = options
|
|
26
26
|
const base_dir = skills_dir(is_global, project_root)
|
|
27
|
+
const lock_dir = lock_root(is_global, project_root)
|
|
27
28
|
|
|
28
|
-
const [lock_errors, lock_data] = await read_lock(
|
|
29
|
+
const [lock_errors, lock_data] = await read_lock(lock_dir)
|
|
29
30
|
if (lock_errors || !lock_data) {
|
|
30
31
|
throw new Error('No skills-lock.json found. Nothing to uninstall.')
|
|
31
32
|
}
|
|
@@ -58,7 +59,7 @@ const uninstall = (skill, options = {}) => catch_errors('Uninstall failed', asyn
|
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
const new_skills = update_lock_skills(lock_data, updates)
|
|
61
|
-
const [write_errors] = await write_lock(
|
|
62
|
+
const [write_errors] = await write_lock(lock_dir, new_skills)
|
|
62
63
|
if (write_errors) throw e('Failed to update lock file', write_errors)
|
|
63
64
|
|
|
64
65
|
print_success(`Removed ${skill}`)
|
package/src/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const { CLI_VERSION, EXIT_CODES, COMMAND_ALIASES, COMMANDS } = require('./constants')
|
|
2
2
|
const { print_error, print_help } = require('./ui/output')
|
|
3
|
-
const { dim } = require('./ui/colors')
|
|
3
|
+
const { dim, yellow } = require('./ui/colors')
|
|
4
4
|
|
|
5
5
|
const levenshtein = (a, b) => {
|
|
6
6
|
const m = a.length, n = b.length
|
|
@@ -92,6 +92,8 @@ Commands:
|
|
|
92
92
|
update [owner/skill] Upgrade to latest versions (alias: up)
|
|
93
93
|
publish Push skill to registry (alias: pub)
|
|
94
94
|
fork <owner/skill> Fork a skill to your workspace
|
|
95
|
+
setup Install the HappySkills CLI skill globally
|
|
96
|
+
self-update Upgrade happyskills CLI to latest version
|
|
95
97
|
login Authenticate with the registry
|
|
96
98
|
logout Clear stored credentials
|
|
97
99
|
whoami Show current user
|
|
@@ -147,6 +149,17 @@ const run = (argv) => {
|
|
|
147
149
|
args.flags._show_help = true
|
|
148
150
|
}
|
|
149
151
|
|
|
152
|
+
if (!args.flags.json && resolved !== 'self-update') {
|
|
153
|
+
const { check_for_update } = require('./utils/update_check')
|
|
154
|
+
const update_info = check_for_update(CLI_VERSION)
|
|
155
|
+
if (update_info && update_info.update_available) {
|
|
156
|
+
process.stderr.write('\n')
|
|
157
|
+
process.stderr.write(yellow(` Update available: v${update_info.current} → v${update_info.latest}\n`))
|
|
158
|
+
process.stderr.write(dim(` Run: npm install -g happyskills@latest\n`))
|
|
159
|
+
process.stderr.write('\n')
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
150
163
|
const command_module = require(`./commands/${resolved}`)
|
|
151
164
|
const result = command_module.run(args)
|
|
152
165
|
|
|
@@ -92,7 +92,7 @@ describe('CLI — global flags', () => {
|
|
|
92
92
|
|
|
93
93
|
it('--help lists all expected commands', () => {
|
|
94
94
|
const { stdout } = run(['--help'])
|
|
95
|
-
const expected_commands = ['install', 'uninstall', 'list', 'search', 'check', 'update', 'publish', 'fork', 'login', 'logout', 'whoami', 'init']
|
|
95
|
+
const expected_commands = ['install', 'uninstall', 'list', 'search', 'check', 'update', 'publish', 'fork', 'login', 'logout', 'whoami', 'init', 'setup', 'self-update']
|
|
96
96
|
for (const cmd of expected_commands) {
|
|
97
97
|
assert.ok(stdout.includes(cmd), `help output should mention "${cmd}"`)
|
|
98
98
|
}
|
|
@@ -163,8 +163,12 @@ describe('CLI — command --help', () => {
|
|
|
163
163
|
['update', 'Aliases:'],
|
|
164
164
|
['publish', 'Aliases:'],
|
|
165
165
|
['fork', 'Arguments:'],
|
|
166
|
-
['init',
|
|
167
|
-
['
|
|
166
|
+
['init', 'Examples:'],
|
|
167
|
+
['setup', 'Options:'],
|
|
168
|
+
['setup', 'Examples:'],
|
|
169
|
+
['self-update', 'Options:'],
|
|
170
|
+
['self-update', 'Examples:'],
|
|
171
|
+
['whoami', '--json'],
|
|
168
172
|
['login', '--browser'],
|
|
169
173
|
['login', '--password'],
|
|
170
174
|
['logout', 'credentials'],
|
|
@@ -381,3 +385,48 @@ describe('CLI — --json: existing json commands now use { data } envelope', ()
|
|
|
381
385
|
}
|
|
382
386
|
})
|
|
383
387
|
})
|
|
388
|
+
|
|
389
|
+
// ─── setup command ─────────────────────────────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
describe('CLI — setup command', () => {
|
|
392
|
+
it('setup --help exits 0 and describes the command', () => {
|
|
393
|
+
const { stdout, code } = run(['setup', '--help'])
|
|
394
|
+
assert.strictEqual(code, 0)
|
|
395
|
+
assert.match(stdout, /happyskillsai\/happyskills-cli/)
|
|
396
|
+
assert.match(stdout, /Options:/)
|
|
397
|
+
assert.match(stdout, /Examples:/)
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
it('setup --json produces a network error (JSON) when API is unreachable', () => {
|
|
401
|
+
const { stdout, code } = run(['setup', '--json'])
|
|
402
|
+
assert.strictEqual(code, 4)
|
|
403
|
+
const out = parse_json_output(stdout, 'setup --json network failure')
|
|
404
|
+
assert.ok('error' in out)
|
|
405
|
+
assert.strictEqual(out.error.code, 'NETWORK_ERROR')
|
|
406
|
+
assert.strictEqual(out.error.exit_code, 4)
|
|
407
|
+
})
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
// ─── self-update command ───────────────────────────────────────────────────────
|
|
411
|
+
|
|
412
|
+
describe('CLI — self-update command', () => {
|
|
413
|
+
it('self-update --help exits 0 and describes the command', () => {
|
|
414
|
+
const { stdout, code } = run(['self-update', '--help'])
|
|
415
|
+
assert.strictEqual(code, 0)
|
|
416
|
+
assert.match(stdout, /npm install -g happyskills@latest/)
|
|
417
|
+
assert.match(stdout, /Options:/)
|
|
418
|
+
assert.match(stdout, /Examples:/)
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
it('self-update --json produces a network error (JSON) when npm registry is unreachable', () => {
|
|
422
|
+
const { stdout, code } = run(
|
|
423
|
+
['self-update', '--json'],
|
|
424
|
+
{ HAPPYSKILLS_NPM_REGISTRY_URL: 'http://localhost:0' }
|
|
425
|
+
)
|
|
426
|
+
assert.strictEqual(code, 4)
|
|
427
|
+
const out = parse_json_output(stdout, 'self-update --json network failure')
|
|
428
|
+
assert.ok('error' in out)
|
|
429
|
+
assert.strictEqual(out.error.code, 'NETWORK_ERROR')
|
|
430
|
+
assert.strictEqual(out.error.exit_code, 4)
|
|
431
|
+
})
|
|
432
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const os = require('os')
|
|
2
|
+
const fs = require('fs')
|
|
3
|
+
const path = require('path')
|
|
4
|
+
const semver = require('semver')
|
|
5
|
+
|
|
6
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000
|
|
7
|
+
const NPM_REGISTRY_URL = process.env.HAPPYSKILLS_NPM_REGISTRY_URL || 'https://registry.npmjs.org/happyskills/latest'
|
|
8
|
+
|
|
9
|
+
const get_cache_path = () => {
|
|
10
|
+
const base = process.env.XDG_CONFIG_HOME
|
|
11
|
+
? path.join(process.env.XDG_CONFIG_HOME, 'happyskills')
|
|
12
|
+
: path.join(os.homedir(), '.config', 'happyskills')
|
|
13
|
+
return path.join(base, 'update-check.json')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const read_cache = () => {
|
|
17
|
+
try {
|
|
18
|
+
const raw = fs.readFileSync(get_cache_path(), 'utf8')
|
|
19
|
+
return JSON.parse(raw)
|
|
20
|
+
} catch {
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const write_cache = (data) => {
|
|
26
|
+
try {
|
|
27
|
+
const cache_path = get_cache_path()
|
|
28
|
+
fs.mkdirSync(path.dirname(cache_path), { recursive: true })
|
|
29
|
+
fs.writeFileSync(cache_path, JSON.stringify(data), 'utf8')
|
|
30
|
+
} catch {
|
|
31
|
+
// silently ignore write errors
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Fire-and-forget — NOT wrapped in catch_errors because it ends with .catch(() => {})
|
|
36
|
+
const refresh_cache = () => {
|
|
37
|
+
fetch(NPM_REGISTRY_URL, { signal: AbortSignal.timeout(5000) })
|
|
38
|
+
.then(r => r.json())
|
|
39
|
+
.then(data => {
|
|
40
|
+
if (data && data.version) {
|
|
41
|
+
write_cache({ latest: data.version, checked_at: Date.now() })
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
.catch(() => {})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const check_for_update = (current_version) => {
|
|
48
|
+
const cached = read_cache()
|
|
49
|
+
const is_stale = !cached || !cached.checked_at || (Date.now() - cached.checked_at > CACHE_TTL_MS)
|
|
50
|
+
if (is_stale) refresh_cache()
|
|
51
|
+
if (!cached || !cached.latest) return null
|
|
52
|
+
const update_available = !!semver.gt(cached.latest, current_version)
|
|
53
|
+
return { update_available, current: current_version, latest: cached.latest }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { check_for_update }
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const { describe, it } = require('node:test')
|
|
3
|
+
const assert = require('node:assert/strict')
|
|
4
|
+
const fs = require('fs')
|
|
5
|
+
const os = require('os')
|
|
6
|
+
const path = require('path')
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Runs fn inside a temporary XDG config dir, restoring the original env
|
|
10
|
+
* value (or deleting the key) after. Since get_cache_path() reads
|
|
11
|
+
* process.env.XDG_CONFIG_HOME on every call, this provides full isolation
|
|
12
|
+
* without needing to re-require the module.
|
|
13
|
+
*/
|
|
14
|
+
const with_tmp_xdg = (fn) => {
|
|
15
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'hs-update-test-'))
|
|
16
|
+
const orig = process.env.XDG_CONFIG_HOME
|
|
17
|
+
process.env.XDG_CONFIG_HOME = tmp
|
|
18
|
+
try {
|
|
19
|
+
return fn(tmp)
|
|
20
|
+
} finally {
|
|
21
|
+
if (orig === undefined) delete process.env.XDG_CONFIG_HOME
|
|
22
|
+
else process.env.XDG_CONFIG_HOME = orig
|
|
23
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const write_cache = (xdg_dir, data) => {
|
|
28
|
+
const cache_dir = path.join(xdg_dir, 'happyskills')
|
|
29
|
+
fs.mkdirSync(cache_dir, { recursive: true })
|
|
30
|
+
fs.writeFileSync(path.join(cache_dir, 'update-check.json'), JSON.stringify(data), 'utf8')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const { check_for_update } = require('./update_check')
|
|
34
|
+
|
|
35
|
+
describe('check_for_update', () => {
|
|
36
|
+
it('returns null when no cache file exists', () => {
|
|
37
|
+
with_tmp_xdg(() => {
|
|
38
|
+
const result = check_for_update('1.0.0')
|
|
39
|
+
assert.strictEqual(result, null)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('returns update_available: true when cached latest is newer', () => {
|
|
44
|
+
with_tmp_xdg((tmp) => {
|
|
45
|
+
write_cache(tmp, { latest: '2.0.0', checked_at: Date.now() })
|
|
46
|
+
const result = check_for_update('1.0.0')
|
|
47
|
+
assert.ok(result !== null)
|
|
48
|
+
assert.strictEqual(result.update_available, true)
|
|
49
|
+
assert.strictEqual(result.current, '1.0.0')
|
|
50
|
+
assert.strictEqual(result.latest, '2.0.0')
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('returns update_available: false when cached latest equals current', () => {
|
|
55
|
+
with_tmp_xdg((tmp) => {
|
|
56
|
+
write_cache(tmp, { latest: '1.0.0', checked_at: Date.now() })
|
|
57
|
+
const result = check_for_update('1.0.0')
|
|
58
|
+
assert.ok(result !== null)
|
|
59
|
+
assert.strictEqual(result.update_available, false)
|
|
60
|
+
assert.strictEqual(result.current, '1.0.0')
|
|
61
|
+
assert.strictEqual(result.latest, '1.0.0')
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('returns update_available: false when cached latest is older than current', () => {
|
|
66
|
+
with_tmp_xdg((tmp) => {
|
|
67
|
+
write_cache(tmp, { latest: '0.5.0', checked_at: Date.now() })
|
|
68
|
+
const result = check_for_update('1.0.0')
|
|
69
|
+
assert.ok(result !== null)
|
|
70
|
+
assert.strictEqual(result.update_available, false)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('returns null when cache is missing the latest field', () => {
|
|
75
|
+
with_tmp_xdg((tmp) => {
|
|
76
|
+
write_cache(tmp, { checked_at: Date.now() })
|
|
77
|
+
const result = check_for_update('1.0.0')
|
|
78
|
+
assert.strictEqual(result, null)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('returns null when cache file contains malformed JSON', () => {
|
|
83
|
+
with_tmp_xdg((tmp) => {
|
|
84
|
+
const cache_dir = path.join(tmp, 'happyskills')
|
|
85
|
+
fs.mkdirSync(cache_dir, { recursive: true })
|
|
86
|
+
fs.writeFileSync(path.join(cache_dir, 'update-check.json'), 'not-valid-json', 'utf8')
|
|
87
|
+
const result = check_for_update('1.0.0')
|
|
88
|
+
assert.strictEqual(result, null)
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('respects XDG_CONFIG_HOME for cache path', () => {
|
|
93
|
+
with_tmp_xdg((tmp) => {
|
|
94
|
+
// Write cache in the XDG-derived path
|
|
95
|
+
write_cache(tmp, { latest: '9.9.9', checked_at: Date.now() })
|
|
96
|
+
const result = check_for_update('1.0.0')
|
|
97
|
+
assert.ok(result !== null, 'should read cache from XDG_CONFIG_HOME-derived path')
|
|
98
|
+
assert.strictEqual(result.latest, '9.9.9')
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('handles patch-level updates correctly', () => {
|
|
103
|
+
with_tmp_xdg((tmp) => {
|
|
104
|
+
write_cache(tmp, { latest: '1.0.1', checked_at: Date.now() })
|
|
105
|
+
const result = check_for_update('1.0.0')
|
|
106
|
+
assert.ok(result !== null)
|
|
107
|
+
assert.strictEqual(result.update_available, true)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
})
|