happyskills 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/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/src/api/repos.js +3 -0
- package/src/api/repos.test.js +16 -0
- package/src/commands/install.js +4 -1
- package/src/engine/downloader.js +2 -2
- package/src/engine/installer.js +27 -2
- package/src/utils/analytics.js +20 -10
- package/src/utils/analytics.test.js +89 -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
|
+
## [1.8.0] - 2026-06-05
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Record installs via a cache-immune `install.completed` beacon. The install engine now emits one `install.completed` event per newly-installed skill — the root **and** each transitive dependency — batched into a single `POST /events`, carrying the skill's `repo_id`, version, and a root-vs-dependency flag. Because it fires from the engine, `setup` is now counted too (previously it emitted nothing). This replaces the per-command `install.completed` event and is the new source of truth for install/download counts, fixing the under-count caused by CDN cache hits on the clone endpoint silently skipping the old counting path. Fire-and-forget — a telemetry failure never blocks or fails an install; an already-up-to-date (no-op) install emits nothing. Requires API v5.5.0+ for `repo_id` stamping and download-count crediting; against older APIs the events are still sent (forward-compatible).
|
|
15
|
+
|
|
16
|
+
## [1.7.1] - 2026-06-04
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- Stop double-counting a single install. The install pipeline tries an archive download first and falls back to a JSON clone when the archive is missing or unusable; both hit the clone endpoint, which counts (`repo.clone` + `download_count`) per request. The fallback now sends `?no_count=1` so one install registers exactly once. Only the install fallback sets it — `fork`, `pull`, and normal installs are unchanged. Takes effect against API v5.4.1+ (older APIs ignore the flag and still count both); forward-only.
|
|
21
|
+
|
|
10
22
|
## [1.7.0] - 2026-06-04
|
|
11
23
|
|
|
12
24
|
### Added
|
package/package.json
CHANGED
package/src/api/repos.js
CHANGED
|
@@ -61,6 +61,9 @@ const clone = (owner, repo, ref, options = {}) => catch_errors(`Clone ${owner}/$
|
|
|
61
61
|
if (options.commit) params.set('commit', options.commit)
|
|
62
62
|
else if (ref) params.set('ref', ref)
|
|
63
63
|
if (options.format) params.set('format', options.format)
|
|
64
|
+
// Fallback retries (install archive→JSON) mark themselves so the server
|
|
65
|
+
// counts the install only once (the primary attempt already counted).
|
|
66
|
+
if (options.no_count) params.set('no_count', '1')
|
|
64
67
|
const qs = params.toString() ? `?${params}` : ''
|
|
65
68
|
const [errors, data] = await client.get(`/repos/${owner}/${repo}/clone${qs}`)
|
|
66
69
|
if (errors) throw errors[errors.length - 1]
|
package/src/api/repos.test.js
CHANGED
|
@@ -217,3 +217,19 @@ describe('repos.star / repos.unstar — endpoint + path', () => {
|
|
|
217
217
|
assert.strictEqual(calls[0][1], `/repos/${encodeURIComponent('acme corp')}/${encodeURIComponent('deploy/aws')}/star`)
|
|
218
218
|
})
|
|
219
219
|
})
|
|
220
|
+
|
|
221
|
+
// Bug B fix: a fallback retry marks itself no_count so the server counts the
|
|
222
|
+
// install only once (the primary archive attempt already counted).
|
|
223
|
+
describe('repos.clone — no_count flag (double-count prevention)', () => {
|
|
224
|
+
afterEach(restore)
|
|
225
|
+
|
|
226
|
+
it('adds ?no_count=1 only when options.no_count is set', async () => {
|
|
227
|
+
const { repos, calls } = stub_client_capture()
|
|
228
|
+
await repos.clone('acme', 'deploy', 'refs/tags/v1.0.0', { no_count: true })
|
|
229
|
+
await repos.clone('acme', 'deploy', 'refs/tags/v1.0.0', {})
|
|
230
|
+
await repos.clone('acme', 'deploy', 'refs/tags/v1.0.0', { format: 'archive' })
|
|
231
|
+
assert.match(calls[0][1], /[?&]no_count=1/)
|
|
232
|
+
assert.doesNotMatch(calls[1][1], /no_count/)
|
|
233
|
+
assert.doesNotMatch(calls[2][1], /no_count/) // archive is the primary attempt — counts
|
|
234
|
+
})
|
|
235
|
+
})
|
package/src/commands/install.js
CHANGED
|
@@ -214,7 +214,10 @@ const run = (args) => catch_errors('Install failed', async () => {
|
|
|
214
214
|
emit_analytics('install.failed', { error_code: 'INSTALL_FAILED', cli_version: CLI_VERSION })
|
|
215
215
|
} else {
|
|
216
216
|
results.push({ skill, result })
|
|
217
|
-
|
|
217
|
+
// install.completed is now emitted per-skill from the install engine
|
|
218
|
+
// (cli/src/engine/installer.js, spec 260604-01) — carrying repo_id,
|
|
219
|
+
// version, and a root-vs-dependency flag — so setup (which bypasses
|
|
220
|
+
// this command) is covered too. No per-command emit here.
|
|
218
221
|
}
|
|
219
222
|
}
|
|
220
223
|
|
package/src/engine/downloader.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
2
2
|
const repos_api = require('../api/repos')
|
|
3
3
|
|
|
4
|
-
const download = (owner, repo, ref) => catch_errors(`Download ${owner}/${repo} failed`, async () => {
|
|
5
|
-
const [errors, data] = await repos_api.clone(owner, repo, ref)
|
|
4
|
+
const download = (owner, repo, ref, options = {}) => catch_errors(`Download ${owner}/${repo} failed`, async () => {
|
|
5
|
+
const [errors, data] = await repos_api.clone(owner, repo, ref, options)
|
|
6
6
|
if (errors) throw e(`Failed to clone ${owner}/${repo}`, errors)
|
|
7
7
|
return data
|
|
8
8
|
})
|
package/src/engine/installer.js
CHANGED
|
@@ -16,6 +16,8 @@ const { resolve_agents, link_to_agents, verify_and_repair_symlinks } = require('
|
|
|
16
16
|
const { is_skill_enabled } = require('../agents/status')
|
|
17
17
|
const { create_spinner } = require('../ui/spinner')
|
|
18
18
|
const { print_success, print_warn, print_info } = require('../ui/output')
|
|
19
|
+
const { emit_batch } = require('../utils/analytics')
|
|
20
|
+
const { CLI_VERSION } = require('../constants')
|
|
19
21
|
|
|
20
22
|
const _format_warnings = (missing_deps) => (missing_deps || []).map(dep => {
|
|
21
23
|
const hint = dep.install_hint ? ` (install: ${dep.install_hint})` : ''
|
|
@@ -204,8 +206,11 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
|
|
|
204
206
|
clone_ref = arch_result.ref
|
|
205
207
|
clone_commit = arch_result.commit
|
|
206
208
|
} else {
|
|
207
|
-
// Fall back to JSON clone
|
|
208
|
-
|
|
209
|
+
// Fall back to JSON clone. The archive attempt above already hit the
|
|
210
|
+
// clone endpoint (and counted the install), so mark this retry
|
|
211
|
+
// no_count to avoid double-counting one install (download_count +
|
|
212
|
+
// repo.clone). See repos.js clone handler.
|
|
213
|
+
const [dl_errors, clone_data] = await download(owner, name, pkg.ref, { no_count: true })
|
|
209
214
|
if (dl_errors) { spinner.fail(`Download failed: ${pkg.skill}`); throw e(`Download ${pkg.skill} failed`, dl_errors) }
|
|
210
215
|
|
|
211
216
|
const [ext_errors] = await extract(clone_data, pkg_tmp)
|
|
@@ -313,6 +318,26 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
|
|
|
313
318
|
}
|
|
314
319
|
}
|
|
315
320
|
|
|
321
|
+
// Cache-immune install counting (spec 260604-01). Fire one
|
|
322
|
+
// install.completed beacon per newly-installed skill — root AND each
|
|
323
|
+
// transitive dependency — batched into a single POST /events. This is the
|
|
324
|
+
// source of truth for install/download counts: it replaces the clone-
|
|
325
|
+
// endpoint side-effect that CloudFront cache HITs silently dropped. Only
|
|
326
|
+
// reached on a genuine install (no_op paths returned earlier and emit
|
|
327
|
+
// nothing). Fire-and-forget — never blocks or fails the install.
|
|
328
|
+
emit_batch(downloaded.map(({ pkg }) => {
|
|
329
|
+
const requested_by = updates[pkg.skill]?.requested_by || []
|
|
330
|
+
return {
|
|
331
|
+
event_type: 'install.completed',
|
|
332
|
+
repo_id: pkg.repo_id || null,
|
|
333
|
+
metadata: {
|
|
334
|
+
version: pkg.version,
|
|
335
|
+
dependency: !requested_by.includes('__root__'),
|
|
336
|
+
cli_version: CLI_VERSION,
|
|
337
|
+
},
|
|
338
|
+
}
|
|
339
|
+
}))
|
|
340
|
+
|
|
316
341
|
spinner.succeed(`Installed ${packages_to_install.length} package(s)`)
|
|
317
342
|
|
|
318
343
|
const linked_count = downloaded.length - disabled_skills.size
|
package/src/utils/analytics.js
CHANGED
|
@@ -7,25 +7,35 @@
|
|
|
7
7
|
|
|
8
8
|
const { error: { catch_errors } } = require('puffy-core')
|
|
9
9
|
|
|
10
|
-
const
|
|
10
|
+
const post_events = (events) => catch_errors('Failed to post analytics events', async () => {
|
|
11
11
|
const { post } = require('../api/client')
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
metadata: metadata || {},
|
|
12
|
+
const stamped = events.map(ev => ({
|
|
13
|
+
event_type: ev.event_type,
|
|
14
|
+
...(ev.repo_id ? { repo_id: ev.repo_id } : {}),
|
|
15
|
+
metadata: ev.metadata || {},
|
|
16
16
|
client_ts: Date.now(),
|
|
17
|
-
}
|
|
17
|
+
}))
|
|
18
|
+
// Use unwrap:false so we don't trip on the empty body.
|
|
19
|
+
await post('/events', { events: stamped }, { auth: true, unwrap: false })
|
|
18
20
|
})
|
|
19
21
|
|
|
20
22
|
// Caller-friendly wrapper. Errors are swallowed — engagement telemetry must
|
|
21
23
|
// never block the user.
|
|
22
|
-
const emit = (event_type, metadata) => {
|
|
24
|
+
const emit = (event_type, metadata) => emit_batch([{ event_type, metadata }])
|
|
25
|
+
|
|
26
|
+
// Batched emit — sends N events in a single POST /events. Used by the install
|
|
27
|
+
// engine to record one install.completed per installed skill (root + each
|
|
28
|
+
// transitive dependency) in one request (spec 260604-01). Each event may carry
|
|
29
|
+
// a top-level `repo_id` (UUID); per-event metadata is passed through as-is.
|
|
30
|
+
const emit_batch = (events) => {
|
|
31
|
+
if (!Array.isArray(events) || events.length === 0) return
|
|
23
32
|
// Don't return the promise — fire-and-forget. Errors stay quiet.
|
|
24
|
-
|
|
33
|
+
post_events(events).then(([errors]) => {
|
|
25
34
|
if (errors && process.env.HAPPYSKILLS_DEBUG) {
|
|
26
|
-
|
|
35
|
+
const types = events.map(e => e.event_type).join(',')
|
|
36
|
+
process.stderr.write(`[analytics] ${types} failed: ${errors[0]?.message || 'unknown'}\n`)
|
|
27
37
|
}
|
|
28
38
|
}).catch(() => {})
|
|
29
39
|
}
|
|
30
40
|
|
|
31
|
-
module.exports = { emit }
|
|
41
|
+
module.exports = { emit, emit_batch }
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Run with: node --test src/utils/analytics.test.js (also covered by `npm test`)
|
|
2
|
+
//
|
|
3
|
+
// Unit tests for the batched analytics emitter (spec 260604-01). The install
|
|
4
|
+
// engine fires one install.completed event per installed skill (root + each
|
|
5
|
+
// dependency) batched into a SINGLE POST /events, with repo_id riding the
|
|
6
|
+
// top-level event field and version/dependency/cli_version in metadata. The
|
|
7
|
+
// emitter is fire-and-forget — it must never throw into the caller.
|
|
8
|
+
|
|
9
|
+
const { describe, it, afterEach } = require('node:test')
|
|
10
|
+
const assert = require('node:assert')
|
|
11
|
+
|
|
12
|
+
// Stub the API client before requiring the module under test, so we capture the
|
|
13
|
+
// exact POST payload emit_batch produces. analytics.js lazy-requires the client
|
|
14
|
+
// inside the post, so seeding require.cache here is enough.
|
|
15
|
+
const stub_client = (calls) => {
|
|
16
|
+
const client_path = require.resolve('../api/client')
|
|
17
|
+
require.cache[client_path] = {
|
|
18
|
+
id: client_path,
|
|
19
|
+
filename: client_path,
|
|
20
|
+
loaded: true,
|
|
21
|
+
exports: {
|
|
22
|
+
post: async (path, body, opts) => { calls.push({ path, body, opts }); return [null, null] },
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
delete require.cache[require.resolve('./analytics')]
|
|
26
|
+
return require('./analytics')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const restore = () => {
|
|
30
|
+
delete require.cache[require.resolve('../api/client')]
|
|
31
|
+
delete require.cache[require.resolve('./analytics')]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// emit_batch is fire-and-forget — give the microtask/promise chain a tick.
|
|
35
|
+
const tick = () => new Promise(r => setImmediate(r))
|
|
36
|
+
|
|
37
|
+
describe('emit_batch — install.completed beacon', () => {
|
|
38
|
+
afterEach(restore)
|
|
39
|
+
|
|
40
|
+
it('sends ONE POST /events for N skills, repo_id top-level, version+dependency in metadata', async () => {
|
|
41
|
+
const calls = []
|
|
42
|
+
const { emit_batch } = stub_client(calls)
|
|
43
|
+
emit_batch([
|
|
44
|
+
{ event_type: 'install.completed', repo_id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', metadata: { version: '1.0.0', dependency: false, cli_version: '9.9.9' } },
|
|
45
|
+
{ event_type: 'install.completed', repo_id: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', metadata: { version: '2.0.0', dependency: true, cli_version: '9.9.9' } },
|
|
46
|
+
])
|
|
47
|
+
await tick()
|
|
48
|
+
|
|
49
|
+
assert.equal(calls.length, 1, 'a constellation install is a single batched request')
|
|
50
|
+
assert.equal(calls[0].path, '/events')
|
|
51
|
+
assert.equal(calls[0].opts.auth, true)
|
|
52
|
+
const evts = calls[0].body.events
|
|
53
|
+
assert.equal(evts.length, 2)
|
|
54
|
+
assert.equal(evts[0].repo_id, 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
|
|
55
|
+
assert.equal(evts[0].metadata.dependency, false)
|
|
56
|
+
assert.equal(evts[0].metadata.version, '1.0.0')
|
|
57
|
+
assert.equal(evts[1].repo_id, 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb')
|
|
58
|
+
assert.equal(evts[1].metadata.dependency, true)
|
|
59
|
+
assert.ok(typeof evts[0].client_ts === 'number', 'each event is timestamped')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('no-ops on an empty batch (zero installed skills → no request)', async () => {
|
|
63
|
+
const calls = []
|
|
64
|
+
const { emit_batch } = stub_client(calls)
|
|
65
|
+
emit_batch([])
|
|
66
|
+
await tick()
|
|
67
|
+
assert.equal(calls.length, 0)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('omits repo_id from the wire when it is null/absent', async () => {
|
|
71
|
+
const calls = []
|
|
72
|
+
const { emit_batch } = stub_client(calls)
|
|
73
|
+
emit_batch([{ event_type: 'install.completed', repo_id: null, metadata: { version: '1.0.0' } }])
|
|
74
|
+
await tick()
|
|
75
|
+
assert.equal('repo_id' in calls[0].body.events[0], false)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('swallows a client error — telemetry never throws into the install flow', async () => {
|
|
79
|
+
const client_path = require.resolve('../api/client')
|
|
80
|
+
require.cache[client_path] = {
|
|
81
|
+
id: client_path, filename: client_path, loaded: true,
|
|
82
|
+
exports: { post: async () => { throw new Error('network down') } },
|
|
83
|
+
}
|
|
84
|
+
delete require.cache[require.resolve('./analytics')]
|
|
85
|
+
const { emit_batch } = require('./analytics')
|
|
86
|
+
assert.doesNotThrow(() => emit_batch([{ event_type: 'install.completed', metadata: {} }]))
|
|
87
|
+
await tick()
|
|
88
|
+
})
|
|
89
|
+
})
|