happyskills 0.1.0 → 0.2.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/CHANGELOG.md +23 -0
- package/README.md +2 -1
- package/bin/happyskills.js +1 -1
- package/package.json +18 -3
- package/src/api/auth.js +13 -7
- package/src/api/push.js +63 -0
- package/src/api/repos.js +6 -6
- package/src/api/upload.js +48 -0
- package/src/api/workspaces.js +1 -1
- package/src/auth/token_store.js +18 -0
- package/src/auth/token_store.test.js +202 -0
- package/src/commands/bump.js +104 -0
- package/src/commands/check.js +15 -4
- package/src/commands/convert.js +189 -0
- package/src/commands/fork.js +16 -2
- package/src/commands/init.js +16 -5
- package/src/commands/install.js +58 -14
- package/src/commands/list.js +29 -8
- package/src/commands/login.js +68 -12
- package/src/commands/login_device.js +69 -0
- package/src/commands/logout.js +8 -1
- package/src/commands/publish.js +54 -60
- package/src/commands/search.js +10 -1
- package/src/commands/uninstall.js +14 -3
- package/src/commands/update.js +25 -7
- package/src/commands/whoami.js +1 -5
- package/src/config/paths.js +27 -2
- package/src/config/paths.test.js +3 -3
- package/src/constants.js +2 -0
- package/src/engine/installer.js +99 -21
- package/src/engine/uninstaller.js +2 -3
- package/src/index.js +47 -4
- package/src/index.test.js +68 -1
- package/src/integration/cli.test.js +383 -0
- package/src/lock/reader.test.js +128 -0
- package/src/manifest/reader.test.js +87 -0
- package/src/manifest/writer.test.js +73 -0
- package/src/state.js +6 -0
- package/src/state.test.js +44 -0
- package/src/ui/colors.js +7 -1
- package/src/ui/colors.test.js +110 -0
- package/src/ui/output.js +65 -5
- package/src/ui/output.test.js +259 -0
- package/src/utils/errors.js +52 -0
- package/src/utils/file_collector.js +49 -0
- package/src/utils/logger.js +89 -0
- package/src/utils/resolve_skill.js +33 -0
- package/src/utils/skill_scanner.js +56 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2026-03-04
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Add `bump` command to increment a skill's version (`patch`, `minor`, `major`, or explicit semver) and update `skills-lock.json` accordingly
|
|
14
|
+
- Add `convert` command to turn an external Claude Code skill (with `SKILL.md` but no registry presence) into a managed HappySkills package — reads frontmatter, generates `skill.json`, publishes, and moves the skill to a namespaced directory
|
|
15
|
+
- Add universal `--json` flag across all 14 commands for structured output (`{ data }` / `{ error }`) enabling LLM and agent automation
|
|
16
|
+
- Add `login --browser` and `login --password` flags to skip the interactive flow selector
|
|
17
|
+
- Add device authorization (browser) flow for `login` — opens a browser tab and polls until the user approves; supports MFA
|
|
18
|
+
- Add `@version` inline install syntax (e.g., `happyskills install acme/skill@1.2.0`)
|
|
19
|
+
- Add `@latest` special version for `install` to force the newest published version, bypassing the lock skip check
|
|
20
|
+
- Add `keywords` field to skill scaffolding in `init` and `convert`
|
|
21
|
+
- Add S3 pre-signed upload path for large skills (5 MB – 10 MB); small skills (< 5 MB) continue to use the direct JSON upload
|
|
22
|
+
- Add display of external Claude Code skills in `list` output — unregistered skills with a `SKILL.md` are shown with source `external`
|
|
23
|
+
- Add token auto-refresh: sessions are transparently renewed using the Cognito `refresh_token` when the access token expires
|
|
24
|
+
- Add error log files written to `~/.config/happyskills/logs/` on non-usage errors, with the log path printed in the terminal
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
- Change skill install directory from nested `owner/name` to flat `name` (e.g., `.claude/skills/deploy-aws/` instead of `.claude/skills/acme/deploy-aws/`)
|
|
28
|
+
- Change `install` without arguments to fall back to `skills-lock.json` when no `skill.json` is present in the current directory
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
- Fix duplicate skill entries appearing in `list` output
|
|
32
|
+
|
|
10
33
|
## [0.1.0] - 2026-02-28
|
|
11
34
|
|
|
12
35
|
### Added
|
package/README.md
CHANGED
|
@@ -39,6 +39,7 @@ happyskills publish
|
|
|
39
39
|
| `check [owner/skill]` | Check for available updates |
|
|
40
40
|
| `update [owner/skill]` | Upgrade to latest compatible versions |
|
|
41
41
|
| `publish` | Push the current skill to the registry |
|
|
42
|
+
| `convert <skill-name>` | Convert an external Claude Code skill into a managed HappySkills package |
|
|
42
43
|
| `fork <owner/skill>` | Fork a skill to your workspace |
|
|
43
44
|
| `login` | Authenticate with the registry |
|
|
44
45
|
| `logout` | Clear stored credentials |
|
|
@@ -63,7 +64,7 @@ happyskills publish
|
|
|
63
64
|
| `--version` | Show CLI version |
|
|
64
65
|
| `-g`, `--global` | Use global scope (`~/.claude/skills/`) |
|
|
65
66
|
| `-y`, `--yes` | Skip confirmation prompts |
|
|
66
|
-
| `--json` | Output as JSON (
|
|
67
|
+
| `--json` | Output as JSON (all commands) |
|
|
67
68
|
|
|
68
69
|
## License
|
|
69
70
|
|
package/bin/happyskills.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
try { require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') }) } catch {}
|
|
3
|
+
try { require('dotenv').config({ path: require('path').join(__dirname, '..', '.env'), quiet: true }) } catch {}
|
|
4
4
|
const { run } = require('../src/index')
|
|
5
5
|
run(process.argv.slice(2))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "happyskills",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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)",
|
|
@@ -17,11 +17,26 @@
|
|
|
17
17
|
"bugs": {
|
|
18
18
|
"url": "https://github.com/cloudlesslabs/skillsbang/issues"
|
|
19
19
|
},
|
|
20
|
-
"keywords": [
|
|
21
|
-
|
|
20
|
+
"keywords": [
|
|
21
|
+
"cli",
|
|
22
|
+
"skills",
|
|
23
|
+
"package-manager",
|
|
24
|
+
"ai",
|
|
25
|
+
"claude",
|
|
26
|
+
"agent",
|
|
27
|
+
"dependency-resolution"
|
|
28
|
+
],
|
|
29
|
+
"files": [
|
|
30
|
+
"bin/",
|
|
31
|
+
"src/",
|
|
32
|
+
"LICENSE",
|
|
33
|
+
"CHANGELOG.md"
|
|
34
|
+
],
|
|
22
35
|
"scripts": {
|
|
23
36
|
"dev": "node bin/happyskills.js",
|
|
24
37
|
"test": "node --test src/**/*.test.js",
|
|
38
|
+
"test:unit": "node --test $(find src -name '*.test.js' ! -path '*/integration/*')",
|
|
39
|
+
"test:integration": "node --test src/integration/cli.test.js",
|
|
25
40
|
"release": "./scripts/release.sh"
|
|
26
41
|
},
|
|
27
42
|
"engines": {
|
package/src/api/auth.js
CHANGED
|
@@ -3,38 +3,44 @@ const client = require('./client')
|
|
|
3
3
|
|
|
4
4
|
const login = (email, password) => catch_errors('Login failed', async () => {
|
|
5
5
|
const [errors, data] = await client.post('/auth/login', { email, password }, { auth: false })
|
|
6
|
-
if (errors) throw errors[
|
|
6
|
+
if (errors) throw errors[errors.length - 1]
|
|
7
7
|
return data
|
|
8
8
|
})
|
|
9
9
|
|
|
10
10
|
const signup = (email, password) => catch_errors('Signup failed', async () => {
|
|
11
11
|
const [errors, data] = await client.post('/auth/signup', { email, password }, { auth: false })
|
|
12
|
-
if (errors) throw errors[
|
|
12
|
+
if (errors) throw errors[errors.length - 1]
|
|
13
13
|
return data
|
|
14
14
|
})
|
|
15
15
|
|
|
16
16
|
const confirm = (email, code) => catch_errors('Confirmation failed', async () => {
|
|
17
17
|
const [errors, data] = await client.post('/auth/confirm', { email, code }, { auth: false })
|
|
18
|
-
if (errors) throw errors[
|
|
18
|
+
if (errors) throw errors[errors.length - 1]
|
|
19
19
|
return data
|
|
20
20
|
})
|
|
21
21
|
|
|
22
22
|
const resend_code = (email) => catch_errors('Resend code failed', async () => {
|
|
23
23
|
const [errors, data] = await client.post('/auth/resend-code', { email }, { auth: false })
|
|
24
|
-
if (errors) throw errors[
|
|
24
|
+
if (errors) throw errors[errors.length - 1]
|
|
25
25
|
return data
|
|
26
26
|
})
|
|
27
27
|
|
|
28
28
|
const device_start = () => catch_errors('Device auth start failed', async () => {
|
|
29
29
|
const [errors, data] = await client.post('/auth/device/start', {}, { auth: false })
|
|
30
|
-
if (errors) throw errors[
|
|
30
|
+
if (errors) throw errors[errors.length - 1]
|
|
31
31
|
return data
|
|
32
32
|
})
|
|
33
33
|
|
|
34
34
|
const device_token = (device_code) => catch_errors('Device auth token failed', async () => {
|
|
35
35
|
const [errors, data] = await client.post('/auth/device/token', { device_code }, { auth: false })
|
|
36
|
-
if (errors) throw errors[
|
|
36
|
+
if (errors) throw errors[errors.length - 1]
|
|
37
37
|
return data
|
|
38
38
|
})
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
const refresh = (refresh_token) => catch_errors('Token refresh failed', async () => {
|
|
41
|
+
const [errors, data] = await client.post('/auth/refresh', { refresh_token }, { auth: false })
|
|
42
|
+
if (errors) throw errors[errors.length - 1]
|
|
43
|
+
return data
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
module.exports = { login, signup, confirm, resend_code, device_start, device_token, refresh }
|
package/src/api/push.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
2
|
+
const repos_api = require('./repos')
|
|
3
|
+
const { initiate_upload, complete_upload, upload_files_parallel } = require('./upload')
|
|
4
|
+
|
|
5
|
+
const DIRECT_PUSH_THRESHOLD = 5 * 1024 * 1024 // 5MB
|
|
6
|
+
|
|
7
|
+
const estimate_payload_size = (files) => {
|
|
8
|
+
let size = 0
|
|
9
|
+
for (const file of files) {
|
|
10
|
+
size += file.content.length + file.path.length + 100 // base64 content + path + JSON overhead
|
|
11
|
+
}
|
|
12
|
+
return size
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const smart_push = (owner, repo, { version, message, files }, on_progress) =>
|
|
16
|
+
catch_errors('Smart push failed', async () => {
|
|
17
|
+
const payload_size = estimate_payload_size(files)
|
|
18
|
+
|
|
19
|
+
if (payload_size < DIRECT_PUSH_THRESHOLD) {
|
|
20
|
+
// Small payload — use direct push
|
|
21
|
+
const [err, data] = await repos_api.push(owner, repo, { version, message, files })
|
|
22
|
+
if (err) throw e('Direct push failed', err)
|
|
23
|
+
return data
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Large payload — use presigned upload flow
|
|
27
|
+
const file_meta = files.map(f => ({ path: f.path, sha: f.sha, size: f.size }))
|
|
28
|
+
|
|
29
|
+
// Step 1: Initiate
|
|
30
|
+
const [init_err, init_data] = await initiate_upload(owner, repo, {
|
|
31
|
+
version, message, files: file_meta
|
|
32
|
+
})
|
|
33
|
+
if (init_err) throw e('Upload initiation failed', init_err)
|
|
34
|
+
|
|
35
|
+
const { upload_id, presigned_urls } = init_data
|
|
36
|
+
|
|
37
|
+
// Step 2: Upload files to S3 (deduplicated by SHA)
|
|
38
|
+
const seen_shas = new Set()
|
|
39
|
+
const files_to_upload = []
|
|
40
|
+
for (const file of files) {
|
|
41
|
+
if (seen_shas.has(file.sha)) continue
|
|
42
|
+
seen_shas.add(file.sha)
|
|
43
|
+
const url = presigned_urls[file.sha]
|
|
44
|
+
if (!url) throw new Error(`No presigned URL for file ${file.path} (sha: ${file.sha})`)
|
|
45
|
+
files_to_upload.push({
|
|
46
|
+
buffer: Buffer.from(file.content, 'base64'),
|
|
47
|
+
url
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const [upload_err] = await upload_files_parallel(files_to_upload, on_progress)
|
|
52
|
+
if (upload_err) throw e('File uploads failed', upload_err)
|
|
53
|
+
|
|
54
|
+
// Step 3: Complete
|
|
55
|
+
const [complete_err, complete_data] = await complete_upload(owner, repo, {
|
|
56
|
+
upload_id, version, message, files: file_meta
|
|
57
|
+
})
|
|
58
|
+
if (complete_err) throw e('Upload completion failed', complete_err)
|
|
59
|
+
|
|
60
|
+
return complete_data
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
module.exports = { smart_push }
|
package/src/api/repos.js
CHANGED
|
@@ -6,38 +6,38 @@ const search = (query, options = {}) => catch_errors('Search failed', async () =
|
|
|
6
6
|
if (options.limit) params.set('limit', options.limit)
|
|
7
7
|
if (options.offset) params.set('offset', options.offset)
|
|
8
8
|
const [errors, data] = await client.get(`/repos/search?${params}`, { auth: false })
|
|
9
|
-
if (errors) throw errors[
|
|
9
|
+
if (errors) throw errors[errors.length - 1]
|
|
10
10
|
return data
|
|
11
11
|
})
|
|
12
12
|
|
|
13
13
|
const resolve_dependencies = (skill, version, installed = {}) => catch_errors('Dependency resolution failed', async () => {
|
|
14
14
|
const [errors, data] = await client.post('/repos:resolve-dependencies', { skill, version, installed })
|
|
15
|
-
if (errors) throw errors[
|
|
15
|
+
if (errors) throw errors[errors.length - 1]
|
|
16
16
|
return data
|
|
17
17
|
})
|
|
18
18
|
|
|
19
19
|
const clone = (owner, repo, ref) => catch_errors(`Clone ${owner}/${repo} failed`, async () => {
|
|
20
20
|
const params = ref ? `?ref=${encodeURIComponent(ref)}` : ''
|
|
21
21
|
const [errors, data] = await client.get(`/repos/${owner}/${repo}/clone${params}`)
|
|
22
|
-
if (errors) throw errors[
|
|
22
|
+
if (errors) throw errors[errors.length - 1]
|
|
23
23
|
return data
|
|
24
24
|
})
|
|
25
25
|
|
|
26
26
|
const push = (owner, repo, body) => catch_errors(`Push to ${owner}/${repo} failed`, async () => {
|
|
27
27
|
const [errors, data] = await client.post(`/repos/${owner}/${repo}/push`, body)
|
|
28
|
-
if (errors) throw errors[
|
|
28
|
+
if (errors) throw errors[errors.length - 1]
|
|
29
29
|
return data
|
|
30
30
|
})
|
|
31
31
|
|
|
32
32
|
const get_refs = (owner, repo) => catch_errors(`Get refs for ${owner}/${repo} failed`, async () => {
|
|
33
33
|
const [errors, data] = await client.get(`/repos/${owner}/${repo}/refs`)
|
|
34
|
-
if (errors) throw errors[
|
|
34
|
+
if (errors) throw errors[errors.length - 1]
|
|
35
35
|
return data
|
|
36
36
|
})
|
|
37
37
|
|
|
38
38
|
const get_repo = (owner, repo) => catch_errors(`Get repo ${owner}/${repo} failed`, async () => {
|
|
39
39
|
const [errors, data] = await client.get(`/repos/${owner}/${repo}`, { auth: false })
|
|
40
|
-
if (errors) throw errors[
|
|
40
|
+
if (errors) throw errors[errors.length - 1]
|
|
41
41
|
return data
|
|
42
42
|
})
|
|
43
43
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
2
|
+
const client = require('./client')
|
|
3
|
+
|
|
4
|
+
const CONCURRENT_UPLOADS = 5
|
|
5
|
+
|
|
6
|
+
const initiate_upload = (owner, repo, body) => catch_errors('Initiate upload failed', async () => {
|
|
7
|
+
const [errors, data] = await client.post(`/repos/${owner}/${repo}/push/initiate`, body)
|
|
8
|
+
if (errors) throw errors[errors.length - 1]
|
|
9
|
+
return data
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const complete_upload = (owner, repo, body) => catch_errors('Complete upload failed', async () => {
|
|
13
|
+
const [errors, data] = await client.post(`/repos/${owner}/${repo}/push/complete`, body)
|
|
14
|
+
if (errors) throw errors[errors.length - 1]
|
|
15
|
+
return data
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const upload_file_to_s3 = (presigned_url, buffer) => catch_errors('S3 upload failed', async () => {
|
|
19
|
+
const res = await fetch(presigned_url, {
|
|
20
|
+
method: 'PUT',
|
|
21
|
+
body: buffer,
|
|
22
|
+
headers: { 'Content-Length': buffer.length.toString() }
|
|
23
|
+
})
|
|
24
|
+
if (!res.ok) {
|
|
25
|
+
throw new Error(`S3 PUT failed with status ${res.status}`)
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const upload_files_parallel = (files_with_urls, on_progress) => catch_errors('Parallel upload failed', async () => {
|
|
30
|
+
let completed = 0
|
|
31
|
+
const total = files_with_urls.length
|
|
32
|
+
|
|
33
|
+
const run_batch = async (batch) => {
|
|
34
|
+
await Promise.all(batch.map(async ({ buffer, url }) => {
|
|
35
|
+
const [err] = await upload_file_to_s3(url, buffer)
|
|
36
|
+
if (err) throw e('File upload failed', err)
|
|
37
|
+
completed++
|
|
38
|
+
if (on_progress) on_progress(completed, total)
|
|
39
|
+
}))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (let i = 0; i < files_with_urls.length; i += CONCURRENT_UPLOADS) {
|
|
43
|
+
const batch = files_with_urls.slice(i, i + CONCURRENT_UPLOADS)
|
|
44
|
+
await run_batch(batch)
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
module.exports = { initiate_upload, complete_upload, upload_file_to_s3, upload_files_parallel }
|
package/src/api/workspaces.js
CHANGED
|
@@ -3,7 +3,7 @@ const client = require('./client')
|
|
|
3
3
|
|
|
4
4
|
const list_workspaces = () => catch_errors('List workspaces failed', async () => {
|
|
5
5
|
const [errors, data] = await client.get('/workspaces')
|
|
6
|
-
if (errors) throw errors[
|
|
6
|
+
if (errors) throw errors[errors.length - 1]
|
|
7
7
|
return data
|
|
8
8
|
})
|
|
9
9
|
|
package/src/auth/token_store.js
CHANGED
|
@@ -3,6 +3,20 @@ const path = require('path')
|
|
|
3
3
|
const { error: { catch_errors } } = require('puffy-core')
|
|
4
4
|
const { credentials_path, config_dir } = require('../config/paths')
|
|
5
5
|
|
|
6
|
+
const _try_refresh = async (refresh_token) => {
|
|
7
|
+
try {
|
|
8
|
+
const { refresh } = require('../api/auth')
|
|
9
|
+
const [errors, data] = await refresh(refresh_token)
|
|
10
|
+
if (errors || !data) return null
|
|
11
|
+
const merged = { ...data, refresh_token }
|
|
12
|
+
const [save_err] = await save_token(merged)
|
|
13
|
+
if (save_err) return null
|
|
14
|
+
return { ...merged, stored_at: new Date().toISOString() }
|
|
15
|
+
} catch {
|
|
16
|
+
return null
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
6
20
|
const save_token = (token_data) => catch_errors('Failed to save token', async () => {
|
|
7
21
|
const creds_path = credentials_path()
|
|
8
22
|
const dir = config_dir()
|
|
@@ -40,6 +54,10 @@ const load_token = () => catch_errors('Failed to load token', async () => {
|
|
|
40
54
|
const now = Date.now()
|
|
41
55
|
const elapsed_sec = (now - stored) / 1000
|
|
42
56
|
if (elapsed_sec >= data.expires_in) {
|
|
57
|
+
if (data.refresh_token) {
|
|
58
|
+
const refreshed = await _try_refresh(data.refresh_token)
|
|
59
|
+
if (refreshed) return refreshed
|
|
60
|
+
}
|
|
43
61
|
await clear_token()
|
|
44
62
|
return null
|
|
45
63
|
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const { describe, it, before, after, beforeEach } = require('node:test')
|
|
3
|
+
const assert = require('node:assert/strict')
|
|
4
|
+
const os = require('os')
|
|
5
|
+
const path = require('path')
|
|
6
|
+
const fs = require('fs')
|
|
7
|
+
|
|
8
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
9
|
+
// token_store reads XDG_CONFIG_HOME lazily (at call time) via config/paths.js,
|
|
10
|
+
// so we can control the credentials location by setting the env var before each
|
|
11
|
+
// test. The module itself can be required once at the top.
|
|
12
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
const { save_token, load_token, clear_token, require_token } = require('./token_store')
|
|
15
|
+
const { AuthError } = require('../utils/errors')
|
|
16
|
+
|
|
17
|
+
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-token-test-'))
|
|
20
|
+
const cleanup = (dir) => fs.rmSync(dir, { recursive: true, force: true })
|
|
21
|
+
|
|
22
|
+
const make_token = (overrides = {}) => ({
|
|
23
|
+
id_token: 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyMTIzIn0.sig',
|
|
24
|
+
access_token: 'access-token-value',
|
|
25
|
+
refresh_token: 'refresh-token-value',
|
|
26
|
+
expires_in: 3600,
|
|
27
|
+
...overrides
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
// Each test uses its own tmp directory to ensure full isolation.
|
|
31
|
+
// We set XDG_CONFIG_HOME to point at it so credentials_path() resolves there.
|
|
32
|
+
const with_tmp = async (fn) => {
|
|
33
|
+
const dir = make_tmp()
|
|
34
|
+
const orig = process.env.XDG_CONFIG_HOME
|
|
35
|
+
process.env.XDG_CONFIG_HOME = dir
|
|
36
|
+
try {
|
|
37
|
+
await fn(dir)
|
|
38
|
+
} finally {
|
|
39
|
+
if (orig !== undefined) process.env.XDG_CONFIG_HOME = orig
|
|
40
|
+
else delete process.env.XDG_CONFIG_HOME
|
|
41
|
+
cleanup(dir)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const creds_file = (dir) => path.join(dir, 'happyskills', 'credentials.json')
|
|
46
|
+
|
|
47
|
+
// ─── save_token ───────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
describe('save_token', () => {
|
|
50
|
+
it('creates the credentials file with the token data', async () => {
|
|
51
|
+
await with_tmp(async (dir) => {
|
|
52
|
+
const token = make_token()
|
|
53
|
+
const [err] = await save_token(token)
|
|
54
|
+
assert.strictEqual(err, null)
|
|
55
|
+
assert.ok(fs.existsSync(creds_file(dir)), 'credentials.json should exist')
|
|
56
|
+
|
|
57
|
+
const content = JSON.parse(fs.readFileSync(creds_file(dir), 'utf-8'))
|
|
58
|
+
assert.strictEqual(content.id_token, token.id_token)
|
|
59
|
+
assert.strictEqual(content.access_token, token.access_token)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('injects a stored_at timestamp into the saved file', async () => {
|
|
64
|
+
await with_tmp(async () => {
|
|
65
|
+
const before = Date.now()
|
|
66
|
+
const [err] = await save_token(make_token())
|
|
67
|
+
assert.strictEqual(err, null)
|
|
68
|
+
const [, loaded] = await load_token()
|
|
69
|
+
assert.ok(loaded.stored_at, 'stored_at should be present')
|
|
70
|
+
const stored_ms = new Date(loaded.stored_at).getTime()
|
|
71
|
+
assert.ok(stored_ms >= before, 'stored_at should be >= the time before save')
|
|
72
|
+
assert.ok(stored_ms <= Date.now(), 'stored_at should be <= now')
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('creates the config directory automatically when it does not exist', async () => {
|
|
77
|
+
await with_tmp(async (dir) => {
|
|
78
|
+
// The happyskills/ subdirectory does not exist yet
|
|
79
|
+
assert.ok(!fs.existsSync(path.join(dir, 'happyskills')))
|
|
80
|
+
const [err] = await save_token(make_token())
|
|
81
|
+
assert.strictEqual(err, null)
|
|
82
|
+
assert.ok(fs.existsSync(creds_file(dir)))
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('writes the file with 0o600 permissions (owner read/write only)', async () => {
|
|
87
|
+
await with_tmp(async (dir) => {
|
|
88
|
+
await save_token(make_token())
|
|
89
|
+
const stat = fs.statSync(creds_file(dir))
|
|
90
|
+
assert.strictEqual(stat.mode & 0o777, 0o600)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// ─── load_token ───────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
describe('load_token', () => {
|
|
98
|
+
it('returns [null, null] when no credentials file exists', async () => {
|
|
99
|
+
await with_tmp(async () => {
|
|
100
|
+
const [err, token] = await load_token()
|
|
101
|
+
assert.strictEqual(err, null)
|
|
102
|
+
assert.strictEqual(token, null)
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('returns the saved token when it has not expired', async () => {
|
|
107
|
+
await with_tmp(async () => {
|
|
108
|
+
const token = make_token({ expires_in: 3600 })
|
|
109
|
+
await save_token(token)
|
|
110
|
+
|
|
111
|
+
const [err, loaded] = await load_token()
|
|
112
|
+
assert.strictEqual(err, null)
|
|
113
|
+
assert.ok(loaded, 'token should be returned')
|
|
114
|
+
assert.strictEqual(loaded.id_token, token.id_token)
|
|
115
|
+
assert.strictEqual(loaded.refresh_token, token.refresh_token)
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('returns null for an expired token that has no refresh_token', async () => {
|
|
120
|
+
await with_tmp(async (dir) => {
|
|
121
|
+
// Write an already-expired token directly, bypassing save_token's
|
|
122
|
+
// stored_at injection so we can backdate it ourselves.
|
|
123
|
+
const expired = {
|
|
124
|
+
id_token: 'expired-id-token',
|
|
125
|
+
access_token: 'expired-access-token',
|
|
126
|
+
expires_in: 1, // 1 second
|
|
127
|
+
stored_at: new Date(Date.now() - 5000).toISOString() // stored 5s ago
|
|
128
|
+
// no refresh_token
|
|
129
|
+
}
|
|
130
|
+
fs.mkdirSync(path.join(dir, 'happyskills'), { recursive: true })
|
|
131
|
+
fs.writeFileSync(creds_file(dir), JSON.stringify(expired), { mode: 0o600 })
|
|
132
|
+
|
|
133
|
+
const [err, token] = await load_token()
|
|
134
|
+
assert.strictEqual(err, null)
|
|
135
|
+
assert.strictEqual(token, null, 'expired token without refresh_token should return null')
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('deletes the credentials file when a token has expired and no refresh_token', async () => {
|
|
140
|
+
await with_tmp(async (dir) => {
|
|
141
|
+
const expired = {
|
|
142
|
+
id_token: 'expired-id-token',
|
|
143
|
+
expires_in: 1,
|
|
144
|
+
stored_at: new Date(Date.now() - 5000).toISOString()
|
|
145
|
+
}
|
|
146
|
+
fs.mkdirSync(path.join(dir, 'happyskills'), { recursive: true })
|
|
147
|
+
fs.writeFileSync(creds_file(dir), JSON.stringify(expired), { mode: 0o600 })
|
|
148
|
+
|
|
149
|
+
await load_token() // should clear the file
|
|
150
|
+
assert.ok(!fs.existsSync(creds_file(dir)), 'expired credentials file should be deleted')
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
// ─── clear_token ──────────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
describe('clear_token', () => {
|
|
158
|
+
it('deletes the credentials file', async () => {
|
|
159
|
+
await with_tmp(async (dir) => {
|
|
160
|
+
await save_token(make_token())
|
|
161
|
+
assert.ok(fs.existsSync(creds_file(dir)))
|
|
162
|
+
|
|
163
|
+
const [err] = await clear_token()
|
|
164
|
+
assert.strictEqual(err, null)
|
|
165
|
+
assert.ok(!fs.existsSync(creds_file(dir)), 'credentials.json should be deleted')
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('succeeds silently when no credentials file exists', async () => {
|
|
170
|
+
await with_tmp(async () => {
|
|
171
|
+
const [err] = await clear_token()
|
|
172
|
+
assert.strictEqual(err, null)
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
// ─── require_token ────────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
describe('require_token', () => {
|
|
180
|
+
it('returns the token when one is saved and valid', async () => {
|
|
181
|
+
await with_tmp(async () => {
|
|
182
|
+
const token = make_token()
|
|
183
|
+
await save_token(token)
|
|
184
|
+
|
|
185
|
+
const result = await require_token()
|
|
186
|
+
assert.ok(result, 'should return token data')
|
|
187
|
+
assert.strictEqual(result.id_token, token.id_token)
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('throws AuthError when no token is stored', async () => {
|
|
192
|
+
await with_tmp(async () => {
|
|
193
|
+
await assert.rejects(
|
|
194
|
+
() => require_token(),
|
|
195
|
+
(err) => {
|
|
196
|
+
assert.ok(err instanceof AuthError, `expected AuthError, got ${err?.constructor?.name}`)
|
|
197
|
+
return true
|
|
198
|
+
}
|
|
199
|
+
)
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
2
|
+
const { read_manifest } = require('../manifest/reader')
|
|
3
|
+
const { write_manifest } = require('../manifest/writer')
|
|
4
|
+
const { validate_manifest } = require('../manifest/validator')
|
|
5
|
+
const { inc, valid } = require('../utils/semver')
|
|
6
|
+
const { resolve_skill_dir } = require('../utils/resolve_skill')
|
|
7
|
+
const { find_project_root } = require('../config/paths')
|
|
8
|
+
const { read_lock, get_all_locked_skills } = require('../lock/reader')
|
|
9
|
+
const { write_lock, update_lock_skills } = require('../lock/writer')
|
|
10
|
+
const { hash_directory } = require('../lock/integrity')
|
|
11
|
+
const { print_help, print_success, print_json } = require('../ui/output')
|
|
12
|
+
const { exit_with_error, UsageError } = require('../utils/errors')
|
|
13
|
+
const { EXIT_CODES } = require('../constants')
|
|
14
|
+
|
|
15
|
+
const BUMP_TYPES = ['patch', 'minor', 'major']
|
|
16
|
+
|
|
17
|
+
const HELP_TEXT = `Usage: happyskills bump <type|version> <skill-name>
|
|
18
|
+
|
|
19
|
+
Bump the version in a skill's skill.json.
|
|
20
|
+
|
|
21
|
+
Arguments:
|
|
22
|
+
type Bump type: patch, minor, or major
|
|
23
|
+
version Explicit semver version (e.g., 2.0.0)
|
|
24
|
+
skill-name Name of the installed skill
|
|
25
|
+
|
|
26
|
+
Examples:
|
|
27
|
+
happyskills bump patch my-skill
|
|
28
|
+
happyskills bump minor deploy-aws
|
|
29
|
+
happyskills bump major my-skill
|
|
30
|
+
happyskills bump 2.0.0 my-skill`
|
|
31
|
+
|
|
32
|
+
const run = (args) => catch_errors('Bump failed', async () => {
|
|
33
|
+
if (args.flags._show_help) {
|
|
34
|
+
print_help(HELP_TEXT)
|
|
35
|
+
return process.exit(EXIT_CODES.SUCCESS)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const input = args._[0]
|
|
39
|
+
const skill_name = args._[1]
|
|
40
|
+
|
|
41
|
+
if (!input || !skill_name) {
|
|
42
|
+
throw new UsageError('Usage: happyskills bump <patch|minor|major|version> <skill-name>')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const [dir_err, dir] = await resolve_skill_dir(skill_name)
|
|
46
|
+
if (dir_err) throw e(`Skill "${skill_name}" not found`, dir_err)
|
|
47
|
+
|
|
48
|
+
const [manifest_err, manifest] = await read_manifest(dir)
|
|
49
|
+
if (manifest_err) throw e('No skill.json found', manifest_err)
|
|
50
|
+
|
|
51
|
+
const old_version = manifest.version
|
|
52
|
+
|
|
53
|
+
if (BUMP_TYPES.includes(input)) {
|
|
54
|
+
const new_version = inc(old_version, input)
|
|
55
|
+
if (!new_version) throw new Error(`Failed to bump version from ${old_version}`)
|
|
56
|
+
manifest.version = new_version
|
|
57
|
+
} else {
|
|
58
|
+
const cleaned = valid(input)
|
|
59
|
+
if (!cleaned) throw new UsageError(`"${input}" is not a valid semver version.`)
|
|
60
|
+
manifest.version = cleaned
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const validation = validate_manifest(manifest)
|
|
64
|
+
if (!validation.valid) {
|
|
65
|
+
throw new Error(`Invalid manifest after bump: ${validation.errors.join(', ')}`)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const [write_err] = await write_manifest(dir, manifest)
|
|
69
|
+
if (write_err) throw e('Failed to update skill.json', write_err)
|
|
70
|
+
|
|
71
|
+
const project_root = find_project_root()
|
|
72
|
+
const [lock_err, lock_data] = await read_lock(project_root)
|
|
73
|
+
if (!lock_err && lock_data) {
|
|
74
|
+
const all_skills = get_all_locked_skills(lock_data)
|
|
75
|
+
const suffix = `/${skill_name}`
|
|
76
|
+
const lock_key = Object.keys(all_skills).find(k => k.endsWith(suffix))
|
|
77
|
+
if (lock_key && all_skills[lock_key]) {
|
|
78
|
+
const [hash_err, integrity] = await hash_directory(dir)
|
|
79
|
+
const updated_entry = {
|
|
80
|
+
...all_skills[lock_key],
|
|
81
|
+
version: manifest.version,
|
|
82
|
+
ref: `refs/tags/v${manifest.version}`
|
|
83
|
+
}
|
|
84
|
+
if (!hash_err && integrity) updated_entry.integrity = integrity
|
|
85
|
+
const updated_skills = update_lock_skills(lock_data, { [lock_key]: updated_entry })
|
|
86
|
+
await write_lock(project_root, updated_skills)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (args.flags.json) {
|
|
91
|
+
const bump_type = BUMP_TYPES.includes(input) ? input : 'explicit'
|
|
92
|
+
print_json({ data: {
|
|
93
|
+
skill: skill_name,
|
|
94
|
+
old_version,
|
|
95
|
+
new_version: manifest.version,
|
|
96
|
+
bump_type
|
|
97
|
+
} })
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
print_success(`${skill_name}: ${old_version} → ${manifest.version}`)
|
|
102
|
+
}).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
|
|
103
|
+
|
|
104
|
+
module.exports = { run }
|