happyskills 1.4.0 → 1.5.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 +11 -0
- package/package.json +1 -1
- package/src/api/client.js +9 -2
- package/src/api/client.test.js +53 -0
- package/src/commands/schema.js +1 -0
- package/src/commands/stats.js +199 -0
- package/src/commands/stats.test.js +123 -0
- package/src/constants.js +1 -0
- package/src/index.js +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.5.0] - 2026-06-04
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Add `stats` command for read-only consumption stats over a bounded time window. Two scopes: `--scope my_activity` (your own installs / updates / searches / uninstalls) and `--scope my_skills_reach` (aggregate reach of skills you authored — never identifies *who* consumed them). Flags: `--metric`, `--period <7d|30d|90d|6mo|12mo>` or `--from`/`--to`, `--group-by`, `--skill <owner/skill>` (`my_skills_reach` only; resolved to its id server-side), and `--json`. Requires auth; calls `POST /me/stats:query` (API v5.3.0+). Surfaced in `happyskills schema`.
|
|
15
|
+
|
|
16
|
+
## [1.4.1] - 2026-06-03
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- Send the `x-amz-content-sha256` payload-hash header on bodyless POST/DELETE requests so CloudFront OAC SigV4 signing accepts them. It was previously set only when a request had a body, so bodyless authenticated mutations (`star`, `unstar`, `delete`, `people remove`, `groups delete`, `access revoke`) were rejected by the Lambda Function URL with a 403 signature mismatch.
|
|
20
|
+
|
|
10
21
|
## [1.4.0] - 2026-06-03
|
|
11
22
|
|
|
12
23
|
### Added
|
package/package.json
CHANGED
package/src/api/client.js
CHANGED
|
@@ -40,8 +40,15 @@ const request = (method, path, options = {}) => catch_errors(`API ${method} ${pa
|
|
|
40
40
|
headers['Content-Type'] = 'application/json'
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
// CloudFront OAC signs requests to the Lambda Function URL with SigV4, which
|
|
44
|
+
// covers the payload hash via x-amz-content-sha256. GET/HEAD carry no body and
|
|
45
|
+
// OAC uses the empty-string hash deterministically, so they work without it.
|
|
46
|
+
// But body-carrying methods (POST/PUT/PATCH/DELETE) MUST send the header or the
|
|
47
|
+
// Function URL rejects the signature with a 403 — and that applies even when the
|
|
48
|
+
// specific request has no body (e.g. `star`/`unstar`, `delete`, `people remove`).
|
|
49
|
+
// The correct hash for an empty body is sha256(''). See docs/gotchas/deployment.md § 2.1.
|
|
50
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
51
|
+
headers['x-amz-content-sha256'] = createHash('sha256').update(body_str || '').digest('hex')
|
|
45
52
|
}
|
|
46
53
|
|
|
47
54
|
let res
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Run with: node --test src/api/client.test.js
|
|
2
|
+
//
|
|
3
|
+
// Pins the x-amz-content-sha256 payload-hash header that CloudFront OAC requires
|
|
4
|
+
// to SigV4-sign requests to the Lambda Function URL. The original bug: the header
|
|
5
|
+
// was set only when a body existed, so bodyless authed mutations (star/unstar,
|
|
6
|
+
// delete, people remove, ...) reached the Function URL without it and were
|
|
7
|
+
// rejected with a 403 signature mismatch. GET/HEAD are exempt — OAC uses the
|
|
8
|
+
// deterministic empty-string hash for them. See docs/gotchas/deployment.md § 2.1.
|
|
9
|
+
|
|
10
|
+
const { describe, it } = require('node:test')
|
|
11
|
+
const assert = require('node:assert/strict')
|
|
12
|
+
const crypto = require('node:crypto')
|
|
13
|
+
const client = require('./client')
|
|
14
|
+
|
|
15
|
+
const EMPTY_SHA = crypto.createHash('sha256').update('').digest('hex')
|
|
16
|
+
|
|
17
|
+
// Run a client call with global.fetch stubbed to capture the outgoing request.
|
|
18
|
+
const capture_request = async (fn) => {
|
|
19
|
+
const orig = global.fetch
|
|
20
|
+
const captured = {}
|
|
21
|
+
global.fetch = async (url, opts) => {
|
|
22
|
+
captured.url = url
|
|
23
|
+
captured.method = opts.method
|
|
24
|
+
captured.headers = opts.headers
|
|
25
|
+
captured.body = opts.body
|
|
26
|
+
return { ok: true, status: 200, headers: { get: () => null }, json: async () => ({ data: {} }) }
|
|
27
|
+
}
|
|
28
|
+
try { await fn() } finally { global.fetch = orig }
|
|
29
|
+
return captured
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('api client — x-amz-content-sha256 (CloudFront OAC SigV4 payload hash)', () => {
|
|
33
|
+
it('bodyless POST sends the empty-string payload hash (regression: star → 403)', async () => {
|
|
34
|
+
const c = await capture_request(() => client.post('/repos/acme/deploy-aws/star', undefined, { auth: false }))
|
|
35
|
+
assert.equal(c.headers['x-amz-content-sha256'], EMPTY_SHA)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('bodyless DELETE sends the empty-string payload hash (regression: unstar/delete → 403)', async () => {
|
|
39
|
+
const c = await capture_request(() => client.del('/repos/acme/deploy-aws/star', { auth: false }))
|
|
40
|
+
assert.equal(c.headers['x-amz-content-sha256'], EMPTY_SHA)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('POST with a body sends the body hash (unchanged)', async () => {
|
|
44
|
+
const c = await capture_request(() => client.post('/repos:search', { q: 'x' }, { auth: false }))
|
|
45
|
+
const expected = crypto.createHash('sha256').update(JSON.stringify({ q: 'x' })).digest('hex')
|
|
46
|
+
assert.equal(c.headers['x-amz-content-sha256'], expected)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('GET does not send the header (OAC uses the deterministic empty hash)', async () => {
|
|
50
|
+
const c = await capture_request(() => client.get('/repos/acme/deploy-aws', { auth: false }))
|
|
51
|
+
assert.equal(c.headers['x-amz-content-sha256'], undefined)
|
|
52
|
+
})
|
|
53
|
+
})
|
package/src/commands/schema.js
CHANGED
|
@@ -58,6 +58,7 @@ const AUDIENCE_HINT = {
|
|
|
58
58
|
diff: 'consumer', pull: 'consumer', snapshot: 'consumer',
|
|
59
59
|
reconcile: 'consumer', enable: 'consumer', disable: 'consumer',
|
|
60
60
|
versions: 'consumer', changelog: 'consumer', bump: 'consumer',
|
|
61
|
+
stats: 'consumer',
|
|
61
62
|
// author
|
|
62
63
|
publish: 'author', convert: 'author', fork: 'author',
|
|
63
64
|
validate: 'author', delete: 'author', visibility: 'author',
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
2
|
+
const client = require('../api/client')
|
|
3
|
+
const { print_help, print_table, print_json, print_info, print_warn, print_label } = require('../ui/output')
|
|
4
|
+
const { bold, dim, cyan, gray } = require('../ui/colors')
|
|
5
|
+
const { exit_with_error, UsageError, AuthError } = require('../utils/errors')
|
|
6
|
+
const { EXIT_CODES } = require('../constants')
|
|
7
|
+
const token_store = require('../auth/token_store')
|
|
8
|
+
|
|
9
|
+
// happyskills stats (spec 260603-03) — authenticated, read-only consumption
|
|
10
|
+
// stats. Flags map 1:1 onto the closed API grammar (POST /me/stats:query); the
|
|
11
|
+
// server validates and is the source of truth. Two scopes:
|
|
12
|
+
// my_activity — your own actions (installs/updates/searches/uninstalls).
|
|
13
|
+
// my_skills_reach — how OTHER people consume skills you authored
|
|
14
|
+
// (aggregate-only, never names a consumer).
|
|
15
|
+
|
|
16
|
+
const METRICS_BY_SCOPE = {
|
|
17
|
+
my_activity: ['installs', 'updates', 'searches', 'uninstalls'],
|
|
18
|
+
my_skills_reach: ['installs_by_others', 'distinct_installers'],
|
|
19
|
+
}
|
|
20
|
+
const GROUP_BY_BY_SCOPE = {
|
|
21
|
+
my_activity: ['none', 'day', 'week', 'month', 'surface'],
|
|
22
|
+
my_skills_reach: ['none', 'day', 'week', 'month', 'skill'],
|
|
23
|
+
}
|
|
24
|
+
const VALID_PRESETS = ['7d', '30d', '90d', '6mo', '12mo']
|
|
25
|
+
|
|
26
|
+
const HELP_TEXT = `Usage: happyskills stats --scope <scope> --metric <metric> [options]
|
|
27
|
+
|
|
28
|
+
Retrieve your HappySkills usage over a bounded time window. Requires login.
|
|
29
|
+
|
|
30
|
+
Two scopes:
|
|
31
|
+
my_activity Your own actions: installs, updates, searches, uninstalls.
|
|
32
|
+
my_skills_reach How others consume skills you authored (aggregate only —
|
|
33
|
+
never identifies who installed).
|
|
34
|
+
|
|
35
|
+
Options:
|
|
36
|
+
--scope <scope> my_activity | my_skills_reach (required)
|
|
37
|
+
--metric <metric> my_activity: installs, updates, searches, uninstalls
|
|
38
|
+
my_skills_reach: installs_by_others, distinct_installers
|
|
39
|
+
--period <preset> ${VALID_PRESETS.join(' | ')}
|
|
40
|
+
--from <ISO date> Explicit window start (use instead of --period)
|
|
41
|
+
--to <ISO date> Explicit window end (default: now)
|
|
42
|
+
--group-by <bucket> my_activity: none, day, week, month, surface
|
|
43
|
+
my_skills_reach: none, day, week, month, skill
|
|
44
|
+
(default: none)
|
|
45
|
+
--skill <owner/skill> Limit my_skills_reach to one skill you authored
|
|
46
|
+
--json Output as JSON envelope
|
|
47
|
+
|
|
48
|
+
Examples:
|
|
49
|
+
happyskills stats --scope my_activity --metric installs --period 30d
|
|
50
|
+
happyskills stats --scope my_activity --metric searches --period 90d --group-by week --json
|
|
51
|
+
happyskills stats --scope my_skills_reach --metric distinct_installers --period 12mo
|
|
52
|
+
happyskills stats --scope my_skills_reach --metric installs_by_others --period 12mo --group-by skill
|
|
53
|
+
happyskills stats --scope my_skills_reach --metric distinct_installers --period 12mo --skill acme/deploy-aws
|
|
54
|
+
happyskills stats --scope my_activity --metric installs --from 2026-05-01 --to 2026-06-01`
|
|
55
|
+
|
|
56
|
+
// Validate flags against the closed grammar and build the request body. Throws
|
|
57
|
+
// UsageError on bad input (server re-validates regardless). Returns the body.
|
|
58
|
+
const build_request = (flags) => {
|
|
59
|
+
const scope = flags.scope
|
|
60
|
+
if (!scope || !METRICS_BY_SCOPE[scope])
|
|
61
|
+
throw new UsageError('--scope is required and must be one of: my_activity, my_skills_reach')
|
|
62
|
+
|
|
63
|
+
const metric = flags.metric
|
|
64
|
+
if (!metric || !METRICS_BY_SCOPE[scope].includes(metric))
|
|
65
|
+
throw new UsageError(`--metric for ${scope} must be one of: ${METRICS_BY_SCOPE[scope].join(', ')}`)
|
|
66
|
+
|
|
67
|
+
const group_by = flags['group-by'] || 'none'
|
|
68
|
+
if (!GROUP_BY_BY_SCOPE[scope].includes(group_by))
|
|
69
|
+
throw new UsageError(`--group-by for ${scope} must be one of: ${GROUP_BY_BY_SCOPE[scope].join(', ')}`)
|
|
70
|
+
|
|
71
|
+
const has_preset = flags.period != null && flags.period !== true
|
|
72
|
+
const has_from = flags.from != null && flags.from !== true
|
|
73
|
+
if (has_preset && has_from)
|
|
74
|
+
throw new UsageError('Use either --period or --from/--to, not both.')
|
|
75
|
+
if (!has_preset && !has_from)
|
|
76
|
+
throw new UsageError(`A time window is required: --period <${VALID_PRESETS.join('|')}> or --from <date>.`)
|
|
77
|
+
|
|
78
|
+
let period
|
|
79
|
+
if (has_preset) {
|
|
80
|
+
if (!VALID_PRESETS.includes(flags.period))
|
|
81
|
+
throw new UsageError(`--period must be one of: ${VALID_PRESETS.join(', ')}`)
|
|
82
|
+
period = { preset: flags.period }
|
|
83
|
+
} else {
|
|
84
|
+
period = { from: flags.from }
|
|
85
|
+
if (flags.to != null && flags.to !== true) period.to = flags.to
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const body = { scope, metric, group_by, period }
|
|
89
|
+
|
|
90
|
+
// --skill narrows my_skills_reach to a single authored skill. The server
|
|
91
|
+
// resolves the owner/skill slug to its id against your owned set.
|
|
92
|
+
const has_skill = flags.skill != null && flags.skill !== true
|
|
93
|
+
if (has_skill) {
|
|
94
|
+
if (scope !== 'my_skills_reach')
|
|
95
|
+
throw new UsageError('--skill is only valid for --scope my_skills_reach.')
|
|
96
|
+
if (!flags.skill.includes('/'))
|
|
97
|
+
throw new UsageError('--skill must be in owner/skill form, e.g. acme/deploy-aws.')
|
|
98
|
+
body.repo_slug = flags.skill
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return body
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const render_human = (data, warnings) => {
|
|
105
|
+
const window = data.period && data.period.from
|
|
106
|
+
? `${String(data.period.from).slice(0, 10)} → ${String(data.period.to).slice(0, 10)}`
|
|
107
|
+
: ''
|
|
108
|
+
console.log(`\n${bold(`${data.scope} · ${data.metric}`)}${window ? dim(` ${window}`) : ''}\n`)
|
|
109
|
+
print_label(' Total', cyan(String(data.total)))
|
|
110
|
+
|
|
111
|
+
if (Array.isArray(data.series) && data.series.length > 0) {
|
|
112
|
+
console.log('')
|
|
113
|
+
const rows = data.series.map(p => [String(p.key), String(p.value)])
|
|
114
|
+
print_table(['Bucket', 'Count'], rows)
|
|
115
|
+
} else if (data.total === 0) {
|
|
116
|
+
print_info('No events in this window.')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (data.period && data.period.available_from) {
|
|
120
|
+
console.log(`\n${gray(`Data available from ${String(data.period.available_from).slice(0, 10)}.`)}`)
|
|
121
|
+
}
|
|
122
|
+
for (const w of (warnings || [])) print_warn(w)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const run = (args) => catch_errors('Stats failed', async () => {
|
|
126
|
+
if (args.flags._show_help) {
|
|
127
|
+
print_help(HELP_TEXT)
|
|
128
|
+
return process.exit(EXIT_CODES.SUCCESS)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const body = build_request(args.flags)
|
|
132
|
+
|
|
133
|
+
// Auth required — fail fast with the standard auth-required envelope
|
|
134
|
+
// (next_step → login) before any network call.
|
|
135
|
+
const [, token_data] = await token_store.load_token()
|
|
136
|
+
if (!token_data) throw new AuthError()
|
|
137
|
+
|
|
138
|
+
// unwrap:false → the API already emits the canonical six-key envelope; we
|
|
139
|
+
// pass its data / warnings / next_step straight through.
|
|
140
|
+
const [errors, resp] = await client.post('/me/stats:query', body, { auth: true, unwrap: false })
|
|
141
|
+
if (errors) throw e('Stats request failed', errors)
|
|
142
|
+
|
|
143
|
+
const data = (resp && resp.data) || {}
|
|
144
|
+
const warnings = (resp && resp.warnings) || []
|
|
145
|
+
const next_step = (resp && resp.next_step) || {}
|
|
146
|
+
|
|
147
|
+
if (args.flags.json) {
|
|
148
|
+
print_json({ data, warnings, next_step })
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
render_human(data, warnings)
|
|
153
|
+
}).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
|
|
154
|
+
|
|
155
|
+
const schema = {
|
|
156
|
+
name: 'stats',
|
|
157
|
+
audience: 'consumer',
|
|
158
|
+
purpose: 'Retrieve your own consumption stats (my_activity) or aggregate reach of skills you authored (my_skills_reach) over a bounded window. Authenticated, read-only; never identifies who consumed your skills.',
|
|
159
|
+
mutation: false,
|
|
160
|
+
interactive_in_text_mode: false,
|
|
161
|
+
input: {
|
|
162
|
+
positional: [],
|
|
163
|
+
flags: [
|
|
164
|
+
{ name: 'scope', type: 'string', required: true, description: 'my_activity | my_skills_reach' },
|
|
165
|
+
{ name: 'metric', type: 'string', required: true, description: 'my_activity: installs|updates|searches|uninstalls; my_skills_reach: installs_by_others|distinct_installers' },
|
|
166
|
+
{ name: 'period', type: 'string', default: undefined, description: 'Window preset: 7d|30d|90d|6mo|12mo (use instead of --from/--to)' },
|
|
167
|
+
{ name: 'from', type: 'string', default: undefined, description: 'Explicit window start (ISO date)' },
|
|
168
|
+
{ name: 'to', type: 'string', default: undefined, description: 'Explicit window end (ISO date, default now)' },
|
|
169
|
+
{ name: 'group-by', type: 'string', default: 'none', description: 'my_activity: none|day|week|month|surface; my_skills_reach: none|day|week|month|skill' },
|
|
170
|
+
{ name: 'skill', type: 'string', default: undefined, description: 'my_skills_reach only: limit to one authored skill (owner/skill)' },
|
|
171
|
+
{ name: 'json', type: 'boolean', default: false, description: 'Output as JSON' },
|
|
172
|
+
],
|
|
173
|
+
},
|
|
174
|
+
output: {
|
|
175
|
+
data_shape: {
|
|
176
|
+
scope: 'string',
|
|
177
|
+
metric: 'string',
|
|
178
|
+
group_by: 'string',
|
|
179
|
+
period: '{ from: string, to: string, available_from: string|null }',
|
|
180
|
+
total: 'number',
|
|
181
|
+
series: 'array<{ key: string, value: number }>',
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
errors: [
|
|
185
|
+
{ code: 'USAGE_ERROR', next_step: { kind: 'routing', action: 'discover_schema' } },
|
|
186
|
+
{ code: 'AUTH_REQUIRED', next_step: { kind: 'recovery', action: 'login' } },
|
|
187
|
+
// Terminal errors the agent surfaces as-is — no recovery action.
|
|
188
|
+
{ code: 'INVALID_BODY' },
|
|
189
|
+
{ code: 'NOT_FOUND' },
|
|
190
|
+
{ code: 'NETWORK_ERROR', next_step: { kind: 'recovery', action: 'retry' } },
|
|
191
|
+
],
|
|
192
|
+
examples: [
|
|
193
|
+
'happyskills stats --scope my_activity --metric installs --period 30d',
|
|
194
|
+
'happyskills stats --scope my_skills_reach --metric distinct_installers --period 12mo --group-by skill',
|
|
195
|
+
'happyskills stats --scope my_skills_reach --metric installs_by_others --period 12mo --skill acme/deploy-aws',
|
|
196
|
+
],
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
module.exports = { run, build_request, schema }
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Unit tests for `happyskills stats` (spec 260603-03). Two surfaces:
|
|
2
|
+
// 1. build_request — flag → closed-grammar validation + body construction.
|
|
3
|
+
// 2. run(--json) — the envelope passthrough: the API already emits the
|
|
4
|
+
// canonical six-key envelope; the command forwards data/warnings/next_step
|
|
5
|
+
// through the CLI's emit_envelope chokepoint unchanged.
|
|
6
|
+
//
|
|
7
|
+
// Covered by `npm test` (src/**/*.test.js).
|
|
8
|
+
|
|
9
|
+
const { describe, it, mock } = require('node:test')
|
|
10
|
+
const assert = require('node:assert/strict')
|
|
11
|
+
const { build_request, run, schema } = require('./stats')
|
|
12
|
+
const client = require('../api/client')
|
|
13
|
+
const token_store = require('../auth/token_store')
|
|
14
|
+
const state = require('../state')
|
|
15
|
+
|
|
16
|
+
describe('build_request — closed grammar', () => {
|
|
17
|
+
it('builds a my_activity preset body', () => {
|
|
18
|
+
assert.deepEqual(build_request({ scope: 'my_activity', metric: 'installs', period: '30d' }), {
|
|
19
|
+
scope: 'my_activity', metric: 'installs', group_by: 'none', period: { preset: '30d' },
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('builds an explicit from/to body with a time grain', () => {
|
|
24
|
+
assert.deepEqual(build_request({ scope: 'my_activity', metric: 'searches', from: '2026-05-01', to: '2026-06-01', 'group-by': 'week' }), {
|
|
25
|
+
scope: 'my_activity', metric: 'searches', group_by: 'week', period: { from: '2026-05-01', to: '2026-06-01' },
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('rejects an out-of-enum scope', () => {
|
|
30
|
+
assert.throws(() => build_request({ scope: 'nope', metric: 'installs', period: '7d' }), /scope/)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('rejects a metric not valid for the scope', () => {
|
|
34
|
+
assert.throws(() => build_request({ scope: 'my_skills_reach', metric: 'searches', period: '7d' }), /metric/)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('rejects a group-by not valid for the scope (surface is my_activity-only)', () => {
|
|
38
|
+
assert.throws(() => build_request({ scope: 'my_skills_reach', metric: 'installs_by_others', period: '7d', 'group-by': 'surface' }), /group-by/)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('requires exactly one time window', () => {
|
|
42
|
+
assert.throws(() => build_request({ scope: 'my_activity', metric: 'installs' }), /time window/)
|
|
43
|
+
assert.throws(() => build_request({ scope: 'my_activity', metric: 'installs', period: '7d', from: '2026-01-01' }), /not both/)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('rejects an out-of-enum preset', () => {
|
|
47
|
+
assert.throws(() => build_request({ scope: 'my_activity', metric: 'installs', period: '13mo' }), /period/)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('maps --skill to repo_slug for my_skills_reach', () => {
|
|
51
|
+
const body = build_request({ scope: 'my_skills_reach', metric: 'installs_by_others', period: '12mo', skill: 'acme/deploy-aws' })
|
|
52
|
+
assert.equal(body.repo_slug, 'acme/deploy-aws')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('rejects --skill on my_activity', () => {
|
|
56
|
+
assert.throws(() => build_request({ scope: 'my_activity', metric: 'installs', period: '30d', skill: 'acme/x' }), /--skill is only valid/)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('rejects a --skill without owner/skill form', () => {
|
|
60
|
+
assert.throws(() => build_request({ scope: 'my_skills_reach', metric: 'installs_by_others', period: '12mo', skill: 'deploy' }), /owner\/skill/)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('schema export', () => {
|
|
65
|
+
it('is non-mutating, consumer-audience, with scope+metric required', () => {
|
|
66
|
+
assert.equal(schema.name, 'stats')
|
|
67
|
+
assert.equal(schema.mutation, false)
|
|
68
|
+
assert.equal(schema.audience, 'consumer')
|
|
69
|
+
const required = schema.input.flags.filter(f => f.required).map(f => f.name).sort()
|
|
70
|
+
assert.deepEqual(required, ['metric', 'scope'])
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('declares the AUTH_REQUIRED → login recovery contract', () => {
|
|
74
|
+
const auth = schema.errors.find(e => e.code === 'AUTH_REQUIRED')
|
|
75
|
+
assert.ok(auth)
|
|
76
|
+
assert.equal(auth.next_step.action, 'login')
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('run --json — envelope passthrough', () => {
|
|
81
|
+
it('forwards the API data + warnings through a six-key envelope on stdout', async (t) => {
|
|
82
|
+
state.set_json_mode()
|
|
83
|
+
t.mock.method(token_store, 'load_token', async () => [null, { id_token: 'tok' }])
|
|
84
|
+
t.mock.method(client, 'post', async () => [null, {
|
|
85
|
+
ok: true,
|
|
86
|
+
data: { scope: 'my_activity', metric: 'installs', group_by: 'none', period: { from: 'a', to: 'b', available_from: null }, total: 5, series: [] },
|
|
87
|
+
error: {},
|
|
88
|
+
next_step: {},
|
|
89
|
+
warnings: ['Data is only available from 2026-05-25; earlier dates in your range return no events.'],
|
|
90
|
+
meta: {},
|
|
91
|
+
}])
|
|
92
|
+
|
|
93
|
+
let printed = null
|
|
94
|
+
const log = mock.method(console, 'log', (s) => { printed = s })
|
|
95
|
+
|
|
96
|
+
await run({ flags: { scope: 'my_activity', metric: 'installs', period: '30d', json: true } })
|
|
97
|
+
log.mock.restore()
|
|
98
|
+
|
|
99
|
+
const env = JSON.parse(printed)
|
|
100
|
+
// Six-key envelope, data forwarded, warning preserved.
|
|
101
|
+
for (const k of ['ok', 'data', 'error', 'next_step', 'warnings', 'meta']) assert.ok(k in env, `missing ${k}`)
|
|
102
|
+
assert.equal(env.ok, true)
|
|
103
|
+
assert.equal(env.data.total, 5)
|
|
104
|
+
assert.equal(env.warnings.length, 1)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('sends the request to POST /me/stats:query with the built body', async (t) => {
|
|
108
|
+
state.set_json_mode()
|
|
109
|
+
t.mock.method(token_store, 'load_token', async () => [null, { id_token: 'tok' }])
|
|
110
|
+
let captured = null
|
|
111
|
+
t.mock.method(client, 'post', async (path, body, opts) => {
|
|
112
|
+
captured = { path, body, opts }
|
|
113
|
+
return [null, { ok: true, data: {}, error: {}, next_step: {}, warnings: [], meta: {} }]
|
|
114
|
+
})
|
|
115
|
+
const log = mock.method(console, 'log', () => {})
|
|
116
|
+
await run({ flags: { scope: 'my_skills_reach', metric: 'distinct_installers', period: '12mo', 'group-by': 'skill', json: true } })
|
|
117
|
+
log.mock.restore()
|
|
118
|
+
|
|
119
|
+
assert.equal(captured.path, '/me/stats:query')
|
|
120
|
+
assert.equal(captured.opts.unwrap, false)
|
|
121
|
+
assert.deepEqual(captured.body, { scope: 'my_skills_reach', metric: 'distinct_installers', group_by: 'skill', period: { preset: '12mo' } })
|
|
122
|
+
})
|
|
123
|
+
})
|
package/src/constants.js
CHANGED
package/src/index.js
CHANGED
|
@@ -131,6 +131,7 @@ Commands:
|
|
|
131
131
|
reconcile <owner/skill> Diagnose and repair lock-vs-disk drift
|
|
132
132
|
release <skill-name> Atomic release: snapshot + validate + bump + publish
|
|
133
133
|
feedback <category> Lodge feedback (bug, wish, compliment, question, other)
|
|
134
|
+
stats Your usage + reach of skills you authored (requires login)
|
|
134
135
|
|
|
135
136
|
Global flags:
|
|
136
137
|
--help Show help for a command
|