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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Package manager for AI agent skills",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Nicolas Dao <nic@cloudlesslabs.com> (https://cloudlesslabs.com)",
@@ -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 [, lock_data] = await read_lock(project_root)
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(project_root, new_skills)
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}`)
@@ -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 }
@@ -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 [, lock_data] = await read_lock(project_root)
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: args.flags.global || false,
61
+ global: is_global,
61
62
  fresh: true,
62
63
  project_root
63
64
  }
@@ -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
@@ -44,7 +44,9 @@ const COMMANDS = [
44
44
  'login',
45
45
  'logout',
46
46
  'whoami',
47
- 'fork'
47
+ 'fork',
48
+ 'setup',
49
+ 'self-update'
48
50
  ]
49
51
 
50
52
  module.exports = {
@@ -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(project_root)
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(project_root, new_skills)
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(project_root)
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(project_root, new_skills)
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', 'Examples:'],
167
- ['whoami', '--json'],
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
+ })