happyskills 0.48.0 → 0.50.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 +66 -0
- package/package.json +5 -1
- package/src/api/client.js +5 -2
- package/src/api/feedback.js +71 -0
- package/src/commands/bump.js +9 -29
- package/src/commands/check.js +36 -4
- package/src/commands/feedback.js +260 -0
- package/src/commands/install.js +96 -11
- package/src/commands/list.js +39 -9
- package/src/commands/publish.js +43 -36
- package/src/commands/pull.js +40 -0
- package/src/commands/reconcile.js +229 -0
- package/src/commands/release.js +451 -0
- package/src/commands/release.test.js +209 -0
- package/src/commands/snapshot.js +252 -0
- package/src/commands/status.js +45 -23
- package/src/config/limits.js +11 -0
- package/src/constants.js +5 -1
- package/src/index.js +4 -0
- package/src/integration/bump.test.js +170 -0
- package/src/integration/drift.test.js +63 -8
- package/src/integration/install_fresh.test.js +167 -0
- package/src/integration/reconcile.test.js +188 -0
- package/src/integration/release.test.js +183 -0
- package/src/lock/verify.js +151 -16
- package/src/lock/verify.test.js +182 -10
- package/src/merge/rebase.js +322 -0
- package/src/merge/rebase.test.js +110 -0
- package/src/snapshot/paths.js +31 -0
- package/src/snapshot/storage.js +237 -0
- package/src/snapshot/storage.test.js +298 -0
- package/src/utils/image_compression.js +70 -0
- package/src/utils/scrub_secrets.js +49 -0
- package/src/validation/file_size_rules.js +20 -9
- package/src/validation/file_size_rules.test.js +23 -18
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,72 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.50.0] - 2026-05-24
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **New `feedback` command** — `happyskills feedback <category> [body] [--subject "..."] [--attach a,b,c] [--json]`. Lodge a bug report, feature wish, compliment, question, or other feedback against the HappySkills platform from the terminal. Five categories: `bug | wish | compliment | question | other`. The body can be supplied inline or omitted to open `$EDITOR` (vim / nano fallback) — same UX as `git commit`. Optional `--subject` (≤ 200 chars) and `--attach` (comma-separated image paths, max 10, PNG/JPEG accepted on the CLI). Auto-captures `cli_version`, `node_version`, `os`, `arch`, `cwd_is_skill_dir`, and `current_skill` (read from a local `skill.json` if present) into the API's silent `client_context`, with secrets scrubbed before sending. `--json` emits the `{ data: { feedback, attachments }, error: null, next_step }` envelope at the response root — the `next_step` is what the `happyskills-help` skill reads to offer the post-creation attachment follow-up.
|
|
15
|
+
- **Image attachments via cherry-picked jimp 1.x** — `cli/src/utils/image_compression.js` compresses each `--attach` file to ≤ 2000 px longest side / JPEG quality 80 / ≤ 1 MB before upload via the two-step pre-signed S3 flow. Uses `@jimp/core` + `@jimp/js-jpeg` + `@jimp/js-png` + `@jimp/plugin-resize` cherry-picked from the jimp 1.x family — NOT the `jimp` meta-package (which pulls in BMP, GIF, TIFF, blur, color, mask, threshold, dither, print, and a dozen other plugins we don't need). WebP is not supported on the CLI in this release (jimp 1.x ships no WebP codec); the web modal still accepts WebP via `browser-image-compression`. **Lazy-loaded**: jimp is only `require()`d when at least one `--attach` is provided, so every other CLI command (`install`, `search`, `publish`, etc.) pays zero require cost. An early-bail check after compression rejects images that still exceed 1 MB with a clean local error before any initiate round-trip.
|
|
16
|
+
- **New `cli/src/api/feedback.js`** — API client for the new `/feedback` endpoints: `create_feedback`, `initiate_attachment_upload`, `put_attachment_to_s3`, `complete_attachment_upload`, `list_feedback`, `get_feedback`. Follows the existing per-resource module pattern (`api/repos.js`, `api/auth.js`, etc.).
|
|
17
|
+
- **New `cli/src/utils/scrub_secrets.js`** — recursively scrubs OpenAI keys (`sk-…`), GitHub tokens (`ghp_…` / `gho_…`), Cognito JWT-shaped strings (`eyJ…`), and any value whose key matches `/(_TOKEN|_KEY|_SECRET|_PASSWORD|_API_KEY)$/i` before `client_context` leaves the CLI. Applied automatically by the feedback command; reusable from any future command that captures user environment data. Mirror of `web/src/lib/scrub-secrets.js` — the regexes must stay in sync.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- **All API calls now send `User-Agent: happyskills-cli/<version>`** (`cli/src/api/client.js`). Required so the API can derive `feedback.source = 'cli'` server-side and never trust the source from the request body. No behaviour change for existing endpoints — none of them inspect User-Agent today; this is forward-compatible plumbing for any future endpoint that wants to know who's calling.
|
|
22
|
+
- **Install size increased from 5.1 MB → ~15 MB** due to jimp 1.x's transitive deps (`zod` 5 MB for runtime type checks; `bare-fs`/`bare-os`/`bare-url` 3.6 MB combined for Bare-runtime polyfills). The cherry-pick keeps jimp itself to 1.4 MB on disk, but the dependency closure dominates. This violates the original spec § 8.3's 8 MB target — accepted as a deliberate trade-off because: (a) **cold-start cost is unaffected** (lazy require means non-feedback commands never load jimp); (b) `npm install -g happyskills` install cost is paid once per machine, not per invocation; (c) the mission-aligned goal of "friction is the enemy of getting feedback at all" outweighs install footprint. A follow-up could investigate shelling out to system `sips` / `magick` to shrink the install — flagged but not in this release.
|
|
23
|
+
|
|
24
|
+
### Notes
|
|
25
|
+
|
|
26
|
+
- New gotcha entries documenting browser-direct-to-S3 patterns (signed Content-Type on PUT, response-Content-Type override on GET, SDK v3.729+ checksum opt-out, bucket CORS as CSRF guard) were added to `docs/gotchas/aws-sdk.md` in the same monorepo change set. These pertain to the API and infra; mentioned here for context since the CLI's `--attach` flow is the other client of that pipeline.
|
|
27
|
+
|
|
28
|
+
## [0.49.0] - 2026-05-23
|
|
29
|
+
|
|
30
|
+
This release combines two streams of work: **spec 260523-02 (Skill Update Determinism)** which introduces four new CLI primitives plus an `ahead` status to make skill update flows deterministic and safe-to-attempt, AND a **bundle-size enforcement** pass that adds a total-bundle cap to pre-publish validation.
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
#### Determinism primitives (spec 260523-02)
|
|
35
|
+
|
|
36
|
+
- **New `snapshot` command** — capture and restore skill state. Subcommands: `create`, `list`, `restore`, `delete`, `prune`. Snapshots live under `.happyskills/snapshots/<workspace>/<skill>/<snapshot_id>/` and contain a full directory copy plus the lock entry. Default retention 10 per skill; auto-prune on create; configurable via `--keep <n>`. Atomic restore via filesystem rename. Surfaced as the safety net for every non-trivial mutation in the new primitives below.
|
|
37
|
+
- **New `reconcile` command** — deterministic drift repair. Diagnoses the drift subtype and either fixes it on the spot (with `--apply <action>`) or emits a structured `next_step` envelope for operator adjudication. **No-ops on `ahead`** (disk version > lock — that's normal authoring, not drift). Replaces the prose drift-repair procedure that previously routed through `install --fresh`.
|
|
38
|
+
- **New `release` command** — atomic release pipeline. Wraps snapshot + validate + bump (when needed) + changelog verification + publish + lock update + snapshot cleanup as a single deterministic CLI invocation. Recognizes the `ahead` state directly: when `skill.json` is already ahead of lock, the disk version IS the version to publish — no revert, no re-bump. On any failure restores the snapshot and returns a structured `next_step` envelope (`fix_validation_errors`, `specify_bump_type`, `provide_changelog`, `pull_rebase_first`, `specify_workspace`, `reconcile_first`, `resolve_bump_disagreement`).
|
|
39
|
+
- **New `pull --rebase` flag** — snapshot-backed rebase-style pull. Captures local edits as patches, fast-forwards to the registry head, reapplies the patches. On rejection emits a structured envelope with per-file expected/actual context so an operator (LLM) can produce a corrected patch without re-reading the entire file. The standard 3-way merge mode remains the default.
|
|
40
|
+
|
|
41
|
+
#### Status taxonomy (spec 260523-02 § 10.5)
|
|
42
|
+
|
|
43
|
+
- **New `ahead` top-level status** in `list`, `status`, and `check` JSON envelopes. Indicates the normal authoring-ahead state (disk version > lock version) — the author has bumped locally and not yet published. Carries an `ahead: { lock_version, disk_version, has_changelog_entry, changelog_version }` object. Replaces the previous false-positive `drift.version_mismatch` classification for this case.
|
|
44
|
+
- **Narrowed `drift` reasons.** The previous `version_mismatch` reason is removed; `drift.reason` now ranges over `regression` (disk semver-LESS than lock), `missing_skill_json`, and `missing_dir`. Drift is now strictly genuine inconsistency in the local install record.
|
|
45
|
+
|
|
46
|
+
#### `install --fresh` hardening (spec 260523-02 § 8.5)
|
|
47
|
+
|
|
48
|
+
- **Pre-flight version-existence check.** Before any disk mutation, `install <skill>@<version> --fresh` calls the registry to confirm `<version>` exists. If not (or registry unreachable), hard-fails with `USAGE_ERROR` (exit 2) and a `VERSION_NOT_FOUND` message. **Closes the silent-fallback footgun** documented in spec 260523-02 § 2.3 — previously the CLI quietly installed the latest published version when the requested version was missing, with no error envelope.
|
|
49
|
+
- **Snapshot-first.** When the skill directory exists, `--fresh` now captures a snapshot before wiping and exposes `data.snapshot_id` in the success response so the operator can restore manually if needed.
|
|
50
|
+
- **New `--force-discard-local` flag.** When the skill directory has local edits (integrity hash differs from `base_integrity`), `--fresh` refuses with `LOCAL_EDITS_PRESENT` (exit 2) unless `--force-discard-local` is passed. Makes the "throw away my edits" intent explicit.
|
|
51
|
+
|
|
52
|
+
#### Bundle-size enforcement
|
|
53
|
+
|
|
54
|
+
- **New `cli/src/config/limits.js`** — single source of truth for the size limits enforced before publish. Exports `MAX_FILE_SIZE` (1 MB per file) and `MAX_TOTAL_SIZE` (1 MB total bundle). The header comment pins this file as the mirror of `api/app/config/limits.js`; drift between the two will cause the API to reject a publish that the CLI just told the user was fine, so they must be bumped together.
|
|
55
|
+
- **New `max_total_size` validation rule** in `cli/src/validation/file_size_rules.js`. The rule sums the bytes of every non-hidden, non-`node_modules` file under the skill directory and fails pre-publish validation when the total exceeds `MAX_TOTAL_SIZE`. Previously only the per-file `max_file_size` rule existed — a skill made of many sub-1 MB files whose total exceeded the bundle cap would pass `happyskills validate` locally and only fail server-side at `POST /push/initiate` with a `PAYLOAD_TOO_LARGE`. The new rule surfaces the same verdict locally with a clearer message (`"Skill bundle exceeds 1MB limit (X.XX MB)"`) before any presigned-URL or upload work happens.
|
|
56
|
+
|
|
57
|
+
### Changed
|
|
58
|
+
|
|
59
|
+
- **`bump` no longer touches the lock file** (spec 260523-02 § 8.6). Previously `bump` updated both `skill.json` and the lock entry atomically. Under the new lock-as-registry-view principle, a local bump is not a registry interaction — the lock catches up at publish time, atomically with registry acceptance. After bump, the skill enters the `ahead` state until the next publish. Hand-editing `skill.json`'s version field is now functionally equivalent to `bump` for the lock contract (both produce `ahead`); `bump` retains its ergonomic advantages (validation, semver arithmetic, remote-exists warning) but is no longer privileged for correctness.
|
|
60
|
+
- **`publish` now writes the lock file on first publish.** Previously the lock-write block was gated on an existing lock file; in fresh projects (no lock yet), first publish succeeded against the registry but left no local lock entry, causing downstream `list`/`status`/`check` to treat the just-published skill as external. Now `publish` initializes a new lock structure if none exists and persists the new entry atomically.
|
|
61
|
+
- **`list`/`status`/`check` human output** updated to surface the `ahead` state distinctly from `drift`. Stale guidance pointing users at `install --fresh` for drift repair replaced with pointers to `reconcile`.
|
|
62
|
+
- **`pull.js`** dispatches to a new `merge/rebase.js` module when `--rebase` is set; the existing 3-way merge mode is unchanged.
|
|
63
|
+
- **`cli/src/validation/file_size_rules.js`** no longer hardcodes its own `1 * 1024 * 1024` literal — it imports `MAX_FILE_SIZE` and `MAX_TOTAL_SIZE` from `cli/src/config/limits.js`. Same numeric behavior for the per-file rule; the user-facing message now interpolates the limit from the constant rather than reading "1MB" verbatim, so a future bump only requires editing the config file.
|
|
64
|
+
- **CLI registers four new commands** in `constants.js` (`snapshot`, `reconcile`, `release`, plus the `pull --rebase` flag on the existing `pull` command). `index.js` help text updated to surface them.
|
|
65
|
+
|
|
66
|
+
### Fixed
|
|
67
|
+
|
|
68
|
+
- **Silent first-publish lock-creation bug.** `publish.js`'s lock-write was wrapped in `if (!lock_err && lock_data)` so a fresh project (no lock file) silently never created one even after a successful registry publish. The newly-published skill then appeared as `external` in `list` output. Unrelated to spec 260523-02 in origin but surfaced during its E2E verification.
|
|
69
|
+
|
|
70
|
+
### Notes for skill authors
|
|
71
|
+
|
|
72
|
+
- The CLI status taxonomy change is observable: code that depended on `drift.reason === "version_mismatch"` should now branch on `status === "ahead"` for the disk > lock case, or on `drift.reason === "regression"` for disk < lock. All first-party HappySkills skills (`happyskills`, `happyskills-publish`, `happyskills-sync`) have been updated and require this CLI version or newer (`requires.happyskills >= 0.49.0`).
|
|
73
|
+
- The `bump` behavior change is observable but strictly safer. No documented contract was broken: bump's lock-update was internal bookkeeping, not a published interface. Third-party code that depended on `bump` keeping the lock in sync should switch to `release` (which performs the atomic update at publish time as designed).
|
|
74
|
+
- `install --fresh @<missing-version>` previously succeeded with a silent fallback; it now fails fast. Strictly safer — any legitimate caller is rare and would benefit from the explicit error.
|
|
75
|
+
|
|
10
76
|
## [0.48.0] - 2026-05-22
|
|
11
77
|
|
|
12
78
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "happyskills",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.50.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)",
|
|
@@ -43,6 +43,10 @@
|
|
|
43
43
|
"node": ">=22.0.0"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
+
"@jimp/core": "^1.6.0",
|
|
47
|
+
"@jimp/js-jpeg": "^1.6.0",
|
|
48
|
+
"@jimp/js-png": "^1.6.0",
|
|
49
|
+
"@jimp/plugin-resize": "^1.6.0",
|
|
46
50
|
"node-diff3": "^3.2.0",
|
|
47
51
|
"puffy-core": "^1.3.1",
|
|
48
52
|
"semver": "^7.6.0",
|
package/src/api/client.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
const { createHash } = require('crypto')
|
|
2
2
|
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
3
|
-
const { API_URL } = require('../constants')
|
|
3
|
+
const { API_URL, CLI_VERSION } = require('../constants')
|
|
4
4
|
const { ApiError, NetworkError, AuthError } = require('../utils/errors')
|
|
5
5
|
const { load_token } = require('../auth/token_store')
|
|
6
6
|
|
|
7
7
|
const get_base_url = () => process.env.HAPPYSKILLS_API_URL || API_URL
|
|
8
|
+
const USER_AGENT = `happyskills-cli/${CLI_VERSION}`
|
|
8
9
|
|
|
9
10
|
const request = (method, path, options = {}) => catch_errors(`API ${method} ${path} failed`, async () => {
|
|
10
11
|
const { body, auth = true, raw_response = false, unwrap = true, headers: extra_headers = {} } = options
|
|
11
12
|
const url = `${get_base_url()}${path}`
|
|
12
|
-
|
|
13
|
+
// User-Agent enables the API to identify CLI traffic (e.g. feedback API
|
|
14
|
+
// derives `source = 'cli'` from this header). Spec 260524-01 § 12.
|
|
15
|
+
const headers = { 'User-Agent': USER_AGENT, ...extra_headers }
|
|
13
16
|
|
|
14
17
|
if (auth) {
|
|
15
18
|
const [, token_data] = await load_token()
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Spec 260524-01 — CLI client for the /feedback API.
|
|
2
|
+
//
|
|
3
|
+
// Returns the full response (envelope unwrapped to `data`) for `create` so the
|
|
4
|
+
// caller can access the `next_step` envelope baked into the API response. The
|
|
5
|
+
// attachment endpoints unwrap normally — callers only need the payload.
|
|
6
|
+
|
|
7
|
+
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
8
|
+
const client = require('./client')
|
|
9
|
+
|
|
10
|
+
// POST /feedback — does NOT unwrap, so the caller can read `next_step` which
|
|
11
|
+
// the API places alongside `feedback` inside `data`. (Our envelope is
|
|
12
|
+
// `{ data: { feedback, next_step } }`.)
|
|
13
|
+
const create_feedback = (payload) => catch_errors('Create feedback failed', async () => {
|
|
14
|
+
const [errors, data] = await client.post('/feedback', payload)
|
|
15
|
+
if (errors) throw errors[errors.length - 1]
|
|
16
|
+
return data // { feedback, next_step }
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const initiate_attachment_upload = (feedback_id, payload) => catch_errors('Initiate attachment upload failed', async () => {
|
|
20
|
+
const [errors, data] = await client.post(`/feedback/${feedback_id}/attachments/initiate`, payload)
|
|
21
|
+
if (errors) throw errors[errors.length - 1]
|
|
22
|
+
return data // { upload_id, storage_key, upload_url, expires_at, original_name }
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const complete_attachment_upload = (feedback_id, payload) => catch_errors('Complete attachment upload failed', async () => {
|
|
26
|
+
const [errors, data] = await client.post(`/feedback/${feedback_id}/attachments/complete`, payload)
|
|
27
|
+
if (errors) throw errors[errors.length - 1]
|
|
28
|
+
return data // { attachment }
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const put_attachment_to_s3 = (presigned_url, buffer) => catch_errors('S3 attachment upload failed', async () => {
|
|
32
|
+
const res = await fetch(presigned_url, {
|
|
33
|
+
method: 'PUT',
|
|
34
|
+
body: buffer,
|
|
35
|
+
headers: {
|
|
36
|
+
'Content-Type': 'image/jpeg',
|
|
37
|
+
'Content-Length': buffer.length.toString()
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
if (!res.ok) {
|
|
41
|
+
const text = await res.text().catch(() => '')
|
|
42
|
+
throw new Error(`S3 PUT failed with status ${res.status}${text ? ': ' + text.slice(0, 200) : ''}`)
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const list_feedback = (query = {}) => catch_errors('List feedback failed', async () => {
|
|
47
|
+
const params = new URLSearchParams()
|
|
48
|
+
if (query.limit != null) params.set('limit', String(query.limit))
|
|
49
|
+
if (query.offset != null) params.set('offset', String(query.offset))
|
|
50
|
+
if (query.category) params.set('category', query.category)
|
|
51
|
+
if (query.status) params.set('status', query.status)
|
|
52
|
+
const qs = params.toString()
|
|
53
|
+
const [errors, data] = await client.get(`/feedback${qs ? '?' + qs : ''}`)
|
|
54
|
+
if (errors) throw errors[errors.length - 1]
|
|
55
|
+
return data
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const get_feedback = (feedback_id) => catch_errors('Get feedback failed', async () => {
|
|
59
|
+
const [errors, data] = await client.get(`/feedback/${feedback_id}`)
|
|
60
|
+
if (errors) throw errors[errors.length - 1]
|
|
61
|
+
return data
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
module.exports = {
|
|
65
|
+
create_feedback,
|
|
66
|
+
initiate_attachment_upload,
|
|
67
|
+
complete_attachment_upload,
|
|
68
|
+
put_attachment_to_s3,
|
|
69
|
+
list_feedback,
|
|
70
|
+
get_feedback
|
|
71
|
+
}
|
package/src/commands/bump.js
CHANGED
|
@@ -6,9 +6,6 @@ const { inc, valid } = require('../utils/semver')
|
|
|
6
6
|
const { resolve_skill_dir } = require('../utils/resolve_skill')
|
|
7
7
|
const { find_project_root } = require('../config/paths')
|
|
8
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 { verify_lock_disk_consistency } = require('../lock/verify')
|
|
12
9
|
const repos_api = require('../api/repos')
|
|
13
10
|
const { print_help, print_success, print_warn, print_json, code } = require('../ui/output')
|
|
14
11
|
const { exit_with_error, UsageError } = require('../utils/errors')
|
|
@@ -52,13 +49,15 @@ const run = (args) => catch_errors('Bump failed', async () => {
|
|
|
52
49
|
|
|
53
50
|
const old_version = manifest.version
|
|
54
51
|
|
|
55
|
-
// Read lock file
|
|
52
|
+
// Read lock file only to resolve the full skill name for the remote
|
|
53
|
+
// existence warning. The lock is NOT updated here — bump only touches
|
|
54
|
+
// skill.json. The lock catches up at publish time, atomically with
|
|
55
|
+
// registry acceptance. (Spec 260523-02 § 8.6.)
|
|
56
56
|
const project_root = find_project_root()
|
|
57
57
|
const [lock_err, lock_data] = await read_lock(project_root)
|
|
58
58
|
let lock_key = null
|
|
59
|
-
let all_skills = null
|
|
60
59
|
if (!lock_err && lock_data) {
|
|
61
|
-
all_skills = get_all_locked_skills(lock_data)
|
|
60
|
+
const all_skills = get_all_locked_skills(lock_data)
|
|
62
61
|
const suffix = `/${skill_name}`
|
|
63
62
|
lock_key = Object.keys(all_skills).find(k => k.endsWith(suffix)) || null
|
|
64
63
|
}
|
|
@@ -90,29 +89,10 @@ const run = (args) => catch_errors('Bump failed', async () => {
|
|
|
90
89
|
const [write_err] = await write_manifest(dir, manifest)
|
|
91
90
|
if (write_err) throw e('Failed to update skill.json', write_err)
|
|
92
91
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
version: manifest.version,
|
|
98
|
-
ref: `refs/tags/v${manifest.version}`
|
|
99
|
-
}
|
|
100
|
-
if (!hash_err && integrity) updated_entry.integrity = integrity
|
|
101
|
-
const updated_skills = update_lock_skills(lock_data, { [lock_key]: updated_entry })
|
|
102
|
-
await write_lock(project_root, updated_skills)
|
|
103
|
-
|
|
104
|
-
// Post-write verification — bump touches both files, so confirm they
|
|
105
|
-
// agree before declaring success. An interrupted bump (e.g. process
|
|
106
|
-
// killed between write_manifest and write_lock above) is exactly the
|
|
107
|
-
// failure mode this guard exists to catch.
|
|
108
|
-
const [, verify_result] = await verify_lock_disk_consistency(updated_entry, dir)
|
|
109
|
-
if (verify_result && !verify_result.ok) {
|
|
110
|
-
throw new Error(
|
|
111
|
-
`Bump completed but lock and disk disagree (lock ${verify_result.expected}, disk ${verify_result.actual || 'none'}). ` +
|
|
112
|
-
`Re-run ${code(`happyskills install ${lock_key} --fresh`)} to restore.`
|
|
113
|
-
)
|
|
114
|
-
}
|
|
115
|
-
}
|
|
92
|
+
// § 8.6: bump no longer updates the lock. The lock represents what was
|
|
93
|
+
// last successfully installed or published from the registry, and a
|
|
94
|
+
// local bump is not a registry interaction. The skill enters the `ahead`
|
|
95
|
+
// state (disk version > lock version) until the next publish.
|
|
116
96
|
|
|
117
97
|
if (args.flags.json) {
|
|
118
98
|
const bump_type = BUMP_TYPES.includes(input) ? input : 'explicit'
|
package/src/commands/check.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
2
2
|
const { read_lock, get_all_locked_skills, get_via } = require('../lock/reader')
|
|
3
|
-
const { verify_lock_disk_consistency } = require('../lock/verify')
|
|
3
|
+
const { verify_lock_disk_consistency, detect_ahead_state } = require('../lock/verify')
|
|
4
4
|
const repos_api = require('../api/repos')
|
|
5
5
|
const { print_help, print_table, print_json, print_info, print_success, print_warn, print_hint, code } = require('../ui/output')
|
|
6
6
|
const { green, yellow, red } = require('../ui/colors')
|
|
@@ -63,21 +63,39 @@ const run = (args) => catch_errors('Check failed', async () => {
|
|
|
63
63
|
// Verify lock-vs-disk consistency for every skill before classifying. Drift
|
|
64
64
|
// must outrank up-to-date / outdated / conflicts because all those statuses
|
|
65
65
|
// trust the lock as a baseline — and drift means the baseline is broken.
|
|
66
|
+
// Also detect the ahead state — disk > lock is normal authoring, not drift,
|
|
67
|
+
// and should be reported as such per §10.5.
|
|
66
68
|
const base_dir = skills_dir(false, project_root)
|
|
67
69
|
const drift_by_skill = {}
|
|
70
|
+
const ahead_by_skill = {}
|
|
68
71
|
await Promise.all(to_check.map(async ([name, data]) => {
|
|
69
72
|
const short_name = name.split('/')[1] || name
|
|
70
73
|
const dir = skill_install_dir(base_dir, short_name)
|
|
71
74
|
const [, drift] = await verify_lock_disk_consistency(data, dir)
|
|
72
|
-
if (drift && !drift.ok)
|
|
75
|
+
if (drift && !drift.ok) {
|
|
76
|
+
drift_by_skill[name] = drift
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
const [, ahead] = await detect_ahead_state(data, dir)
|
|
80
|
+
if (ahead && ahead.ahead) ahead_by_skill[name] = ahead
|
|
73
81
|
}))
|
|
74
82
|
|
|
83
|
+
const build_ahead_obj = (ahead) => ({
|
|
84
|
+
lock_version: ahead.lock_version,
|
|
85
|
+
disk_version: ahead.disk_version,
|
|
86
|
+
has_changelog_entry: ahead.has_changelog_entry || false,
|
|
87
|
+
changelog_version: ahead.changelog_version || null
|
|
88
|
+
})
|
|
89
|
+
|
|
75
90
|
const results = []
|
|
76
91
|
if (batch_err) {
|
|
77
92
|
for (const [name, data] of to_check) {
|
|
78
93
|
const drift = drift_by_skill[name]
|
|
94
|
+
const ahead = ahead_by_skill[name]
|
|
79
95
|
if (drift) {
|
|
80
96
|
results.push({ skill: name, installed: data.version, latest: '-', status: 'drift', via: get_via(data), drift: { reason: drift.reason, lock_version: drift.expected, disk_version: drift.actual } })
|
|
97
|
+
} else if (ahead) {
|
|
98
|
+
results.push({ skill: name, installed: data.version, latest: '-', status: 'ahead', via: get_via(data), ahead: build_ahead_obj(ahead) })
|
|
81
99
|
} else {
|
|
82
100
|
results.push({ skill: name, installed: data.version, latest: 'error', status: 'error', via: get_via(data) })
|
|
83
101
|
}
|
|
@@ -88,8 +106,11 @@ const run = (args) => catch_errors('Check failed', async () => {
|
|
|
88
106
|
const via = get_via(data)
|
|
89
107
|
const has_conflicts = (data.conflict_files || []).length > 0
|
|
90
108
|
const drift = drift_by_skill[name]
|
|
109
|
+
const ahead = ahead_by_skill[name]
|
|
91
110
|
if (drift) {
|
|
92
111
|
results.push({ skill: name, installed: data.version, latest: info?.latest_version || '-', status: 'drift', via, drift: { reason: drift.reason, lock_version: drift.expected, disk_version: drift.actual } })
|
|
112
|
+
} else if (ahead) {
|
|
113
|
+
results.push({ skill: name, installed: data.version, latest: info?.latest_version || '-', status: 'ahead', via, ahead: build_ahead_obj(ahead) })
|
|
93
114
|
} else if (has_conflicts) {
|
|
94
115
|
results.push({ skill: name, installed: data.version, latest: info?.latest_version || '-', status: 'conflicts', via })
|
|
95
116
|
} else if (info?.access_denied) {
|
|
@@ -112,7 +133,8 @@ const run = (args) => catch_errors('Check failed', async () => {
|
|
|
112
133
|
const up_to_date_count = results.filter(r => r.status === 'up-to-date').length
|
|
113
134
|
const conflicts_count = results.filter(r => r.status === 'conflicts').length
|
|
114
135
|
const drift_count = results.filter(r => r.status === 'drift').length
|
|
115
|
-
|
|
136
|
+
const ahead_count = results.filter(r => r.status === 'ahead').length
|
|
137
|
+
print_json({ data: { results, outdated_count, up_to_date_count, conflicts_count, drift_count, ahead_count } })
|
|
116
138
|
return
|
|
117
139
|
}
|
|
118
140
|
|
|
@@ -121,6 +143,7 @@ const run = (args) => catch_errors('Check failed', async () => {
|
|
|
121
143
|
'outdated': yellow,
|
|
122
144
|
'conflicts': red,
|
|
123
145
|
'drift': red,
|
|
146
|
+
'ahead': (s) => s,
|
|
124
147
|
'no-access': yellow,
|
|
125
148
|
'error': red,
|
|
126
149
|
'unknown': (s) => s
|
|
@@ -153,7 +176,16 @@ const run = (args) => catch_errors('Check failed', async () => {
|
|
|
153
176
|
const disk = d.drift?.disk_version || 'none'
|
|
154
177
|
console.error(` - ${d.skill} (lock ${d.drift?.lock_version}, disk ${disk})`)
|
|
155
178
|
}
|
|
156
|
-
print_hint(`
|
|
179
|
+
print_hint(`Repair with ${code('happyskills reconcile <skill>')}.`)
|
|
180
|
+
}
|
|
181
|
+
const ahead_results = results.filter(r => r.status === 'ahead')
|
|
182
|
+
if (ahead_results.length > 0) {
|
|
183
|
+
console.log()
|
|
184
|
+
print_info(`${ahead_results.length} skill(s) ahead of lock — bumped locally, not yet published.`)
|
|
185
|
+
for (const a of ahead_results) {
|
|
186
|
+
console.error(` - ${a.skill} (lock ${a.ahead?.lock_version}, disk ${a.ahead?.disk_version})`)
|
|
187
|
+
}
|
|
188
|
+
print_hint(`Publish with ${code('happyskills publish <skill>')}.`)
|
|
157
189
|
}
|
|
158
190
|
if (conflicts.length > 0) {
|
|
159
191
|
console.log()
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// Spec 260524-01 — `happyskills feedback` command.
|
|
2
|
+
//
|
|
3
|
+
// Surfaces:
|
|
4
|
+
// happyskills feedback <category> [body] [--subject "..."] [--attach a,b,c] [--json]
|
|
5
|
+
//
|
|
6
|
+
// --json mode emits the API's response wrapped in the universal CLI envelope:
|
|
7
|
+
// { data: { feedback, next_step, attachments? }, error: null }
|
|
8
|
+
// The `next_step` envelope is what the `happyskills-help` skill reads to
|
|
9
|
+
// route the post-creation conversation (offer attachment, etc.) — see spec
|
|
10
|
+
// § 6.2 + § 11.
|
|
11
|
+
//
|
|
12
|
+
// LAZY-LOAD: jimp / image_compression is only require()d when --attach is
|
|
13
|
+
// present, so the 99% of CLI invocations that don't lodge feedback pay zero
|
|
14
|
+
// startup cost (D8).
|
|
15
|
+
|
|
16
|
+
const fs = require('fs')
|
|
17
|
+
const os = require('os')
|
|
18
|
+
const path = require('path')
|
|
19
|
+
const { spawnSync } = require('child_process')
|
|
20
|
+
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
21
|
+
const { require_token } = require('../auth/token_store')
|
|
22
|
+
const feedback_api = require('../api/feedback')
|
|
23
|
+
const { scrub } = require('../utils/scrub_secrets')
|
|
24
|
+
const { print_help, print_json, print_success, print_info, print_hint } = require('../ui/output')
|
|
25
|
+
const { exit_with_error, UsageError } = require('../utils/errors')
|
|
26
|
+
const { EXIT_CODES, CLI_VERSION } = require('../constants')
|
|
27
|
+
|
|
28
|
+
const HELP_TEXT = `Usage: happyskills feedback <category> [body] [options]
|
|
29
|
+
|
|
30
|
+
Lodge feedback with HappySkills — bug reports, feature wishes, compliments,
|
|
31
|
+
questions. Auto-captures CLI version / OS / current skill context silently
|
|
32
|
+
(secrets are scrubbed before sending).
|
|
33
|
+
|
|
34
|
+
Arguments:
|
|
35
|
+
category One of: bug | wish | compliment | question | other
|
|
36
|
+
body Your feedback. Omit to open $EDITOR (vim / nano fallback).
|
|
37
|
+
|
|
38
|
+
Options:
|
|
39
|
+
--subject "..." Short title (max 200 chars)
|
|
40
|
+
--attach <paths> Comma-separated image paths (max 10, PNG/JPEG accepted).
|
|
41
|
+
Each compressed to ≤2000px / quality 80 / JPEG before upload.
|
|
42
|
+
Per-image cap after compression: 1 MB (server-enforced).
|
|
43
|
+
--json Emit JSON envelope (intended for use inside an agentic
|
|
44
|
+
session — see the happyskills-help skill).
|
|
45
|
+
|
|
46
|
+
Examples:
|
|
47
|
+
happyskills feedback bug "search returns nothing for partial slugs"
|
|
48
|
+
happyskills feedback wish "I wish I could export my catalog to JSON"
|
|
49
|
+
happyskills feedback bug --attach screenshot.png --json
|
|
50
|
+
happyskills feedback compliment # → opens $EDITOR for the body`
|
|
51
|
+
|
|
52
|
+
const VALID_CATEGORIES = new Set(['bug', 'wish', 'compliment', 'question', 'other'])
|
|
53
|
+
|
|
54
|
+
const try_editor_for_body = () => {
|
|
55
|
+
const editors = [
|
|
56
|
+
process.env.EDITOR,
|
|
57
|
+
process.env.VISUAL,
|
|
58
|
+
'vim',
|
|
59
|
+
'nano',
|
|
60
|
+
].filter(Boolean)
|
|
61
|
+
|
|
62
|
+
const tmp = path.join(os.tmpdir(), `happyskills-feedback-${process.pid}-${Date.now()}.txt`)
|
|
63
|
+
fs.writeFileSync(tmp, '# Write your feedback above this line. Lines starting with # are ignored.\n')
|
|
64
|
+
|
|
65
|
+
let succeeded = false
|
|
66
|
+
for (const editor of editors) {
|
|
67
|
+
try {
|
|
68
|
+
const r = spawnSync(editor, [tmp], { stdio: 'inherit' })
|
|
69
|
+
if (r.status === 0) { succeeded = true; break }
|
|
70
|
+
} catch (_) {
|
|
71
|
+
// try next
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (!succeeded) {
|
|
75
|
+
try { fs.unlinkSync(tmp) } catch (_) {}
|
|
76
|
+
throw new UsageError('Could not open an editor. Set $EDITOR, or pass the body as a positional argument.')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const raw = fs.readFileSync(tmp, 'utf-8')
|
|
80
|
+
try { fs.unlinkSync(tmp) } catch (_) {}
|
|
81
|
+
const body = raw
|
|
82
|
+
.split('\n')
|
|
83
|
+
.filter(line => !line.startsWith('#'))
|
|
84
|
+
.join('\n')
|
|
85
|
+
.trim()
|
|
86
|
+
return body
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Best-effort: read a local skill.json if the cwd looks like a skill directory.
|
|
90
|
+
const read_current_skill = () => {
|
|
91
|
+
try {
|
|
92
|
+
const p = path.join(process.cwd(), 'skill.json')
|
|
93
|
+
if (!fs.existsSync(p)) return null
|
|
94
|
+
const raw = fs.readFileSync(p, 'utf-8')
|
|
95
|
+
const parsed = JSON.parse(raw)
|
|
96
|
+
const name = parsed.name || null
|
|
97
|
+
if (!name) return null
|
|
98
|
+
// name may be "ns/skill" or just "skill"
|
|
99
|
+
const slash = name.indexOf('/')
|
|
100
|
+
return {
|
|
101
|
+
namespace: slash > 0 ? name.slice(0, slash) : null,
|
|
102
|
+
name: slash > 0 ? name.slice(slash + 1) : name,
|
|
103
|
+
version: parsed.version || null
|
|
104
|
+
}
|
|
105
|
+
} catch (_) {
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const build_client_context = () => {
|
|
111
|
+
const current_skill = read_current_skill()
|
|
112
|
+
const ctx = {
|
|
113
|
+
source: 'cli',
|
|
114
|
+
cli_version: CLI_VERSION,
|
|
115
|
+
node_version: process.version,
|
|
116
|
+
os: process.platform,
|
|
117
|
+
arch: process.arch,
|
|
118
|
+
cwd_is_skill_dir: !!current_skill,
|
|
119
|
+
}
|
|
120
|
+
if (current_skill) ctx.current_skill = current_skill
|
|
121
|
+
return scrub(ctx)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const MAX_ATTACHMENTS = 10
|
|
125
|
+
const MAX_ATTACHMENT_BYTES = 1 * 1000 * 1000 // 1 MB post-compression — matches API MAX_FEEDBACK_ATTACHMENT_BYTES
|
|
126
|
+
|
|
127
|
+
const parse_attach_paths = (raw_value) => {
|
|
128
|
+
if (!raw_value) return []
|
|
129
|
+
const items = String(raw_value).split(',').map(s => s.trim()).filter(Boolean)
|
|
130
|
+
if (items.length > MAX_ATTACHMENTS) {
|
|
131
|
+
throw new UsageError(`At most ${MAX_ATTACHMENTS} attachments are allowed.`)
|
|
132
|
+
}
|
|
133
|
+
for (const p of items) {
|
|
134
|
+
if (!fs.existsSync(p)) {
|
|
135
|
+
throw new UsageError(`Attachment not found: ${p}`)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return items
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const upload_attachments = (feedback_id, attach_paths, opts) => catch_errors('Failed to upload attachments', async () => {
|
|
142
|
+
const { print_progress } = opts
|
|
143
|
+
// Lazy import — keeps jimp out of the require graph for non-feedback commands.
|
|
144
|
+
const { compress_image } = require('../utils/image_compression')
|
|
145
|
+
|
|
146
|
+
const uploaded = []
|
|
147
|
+
for (let i = 0; i < attach_paths.length; i++) {
|
|
148
|
+
const src = attach_paths[i]
|
|
149
|
+
if (print_progress) print_progress(`Compressing ${path.basename(src)} (${i + 1}/${attach_paths.length})`)
|
|
150
|
+
|
|
151
|
+
const [c_err, compressed] = await compress_image(src)
|
|
152
|
+
if (c_err) throw e(`Failed to compress ${src}`, c_err)
|
|
153
|
+
|
|
154
|
+
// Early bail before paying for an initiate round-trip if the compressed
|
|
155
|
+
// image still exceeds the per-image cap. Server enforces the same limit;
|
|
156
|
+
// this is a clean local error rather than a 413 round trip away.
|
|
157
|
+
if (compressed.bytes > MAX_ATTACHMENT_BYTES) {
|
|
158
|
+
throw new UsageError(`Compressed "${path.basename(src)}" is ${compressed.bytes} bytes — exceeds the ${MAX_ATTACHMENT_BYTES}-byte (1 MB) per-image cap. Try cropping or sending a smaller region.`)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const [init_err, init] = await feedback_api.initiate_attachment_upload(feedback_id, {
|
|
162
|
+
bytes: compressed.bytes,
|
|
163
|
+
content_type: 'image/jpeg',
|
|
164
|
+
original_name: path.basename(src),
|
|
165
|
+
})
|
|
166
|
+
if (init_err) throw e(`Failed to initiate upload for ${src}`, init_err)
|
|
167
|
+
|
|
168
|
+
if (print_progress) print_progress(`Uploading ${path.basename(src)} (${i + 1}/${attach_paths.length})`)
|
|
169
|
+
const [put_err] = await feedback_api.put_attachment_to_s3(init.upload_url, compressed.buffer)
|
|
170
|
+
if (put_err) throw e(`S3 upload failed for ${src}`, put_err)
|
|
171
|
+
|
|
172
|
+
const [comp_err, comp] = await feedback_api.complete_attachment_upload(feedback_id, {
|
|
173
|
+
upload_id: init.upload_id,
|
|
174
|
+
bytes: compressed.bytes,
|
|
175
|
+
width: compressed.width,
|
|
176
|
+
height: compressed.height,
|
|
177
|
+
original_name: path.basename(src),
|
|
178
|
+
})
|
|
179
|
+
if (comp_err) throw e(`Failed to confirm upload for ${src}`, comp_err)
|
|
180
|
+
|
|
181
|
+
uploaded.push(comp.attachment)
|
|
182
|
+
}
|
|
183
|
+
return uploaded
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
const run = (args) => catch_errors('Feedback command failed', async () => {
|
|
187
|
+
if (args.flags._show_help) {
|
|
188
|
+
print_help(HELP_TEXT)
|
|
189
|
+
return process.exit(EXIT_CODES.SUCCESS)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const json_mode = !!args.flags.json
|
|
193
|
+
const category = args._[0]
|
|
194
|
+
const body_positional = args._.slice(1).join(' ').trim()
|
|
195
|
+
|
|
196
|
+
if (!category) {
|
|
197
|
+
throw new UsageError(`Category is required. One of: ${Array.from(VALID_CATEGORIES).join(' | ')}`)
|
|
198
|
+
}
|
|
199
|
+
if (!VALID_CATEGORIES.has(category)) {
|
|
200
|
+
throw new UsageError(`Invalid category "${category}". Must be one of: ${Array.from(VALID_CATEGORIES).join(' | ')}`)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let body = body_positional
|
|
204
|
+
if (!body) {
|
|
205
|
+
if (json_mode) {
|
|
206
|
+
// In agentic / scripted contexts we can't open an editor — fail fast.
|
|
207
|
+
throw new UsageError('In --json mode the feedback body must be passed as a positional argument; the editor fallback is interactive.')
|
|
208
|
+
}
|
|
209
|
+
body = try_editor_for_body()
|
|
210
|
+
if (!body) {
|
|
211
|
+
throw new UsageError('No feedback body was entered. Aborted.')
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const subject = args.flags.subject && typeof args.flags.subject === 'string' ? args.flags.subject : null
|
|
216
|
+
const attach_paths = parse_attach_paths(args.flags.attach)
|
|
217
|
+
|
|
218
|
+
// Auth required — surfaces a clean AuthError exit code 3 on no/expired token.
|
|
219
|
+
await require_token()
|
|
220
|
+
|
|
221
|
+
const client_context = build_client_context()
|
|
222
|
+
|
|
223
|
+
const [create_err, create_result] = await feedback_api.create_feedback({
|
|
224
|
+
category,
|
|
225
|
+
subject,
|
|
226
|
+
body,
|
|
227
|
+
client_context,
|
|
228
|
+
})
|
|
229
|
+
if (create_err) throw e('Failed to create feedback', create_err)
|
|
230
|
+
|
|
231
|
+
const { feedback, next_step } = create_result
|
|
232
|
+
|
|
233
|
+
// Attachment phase
|
|
234
|
+
let attachments = []
|
|
235
|
+
if (attach_paths.length > 0) {
|
|
236
|
+
const print_progress = json_mode ? null : (msg) => print_info(msg)
|
|
237
|
+
const [upload_err, uploaded] = await upload_attachments(feedback.id, attach_paths, { print_progress })
|
|
238
|
+
if (upload_err) throw e('Attachment upload failed', upload_err)
|
|
239
|
+
attachments = uploaded
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (json_mode) {
|
|
243
|
+
// Universal CLI envelope shape. The skill's envelope-reader (per spec
|
|
244
|
+
// § 11) consumes `next_step` at the response root.
|
|
245
|
+
const data = { feedback, attachments }
|
|
246
|
+
print_json({ data, error: null, next_step })
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Human-readable
|
|
251
|
+
const short_id = String(feedback.id).slice(0, 8)
|
|
252
|
+
print_success(`Feedback recorded (#${short_id}).`)
|
|
253
|
+
if (attachments.length > 0) {
|
|
254
|
+
print_info(`Uploaded ${attachments.length} attachment${attachments.length === 1 ? '' : 's'}.`)
|
|
255
|
+
} else if (next_step && next_step.attachments_supported && attach_paths.length === 0) {
|
|
256
|
+
print_hint(`Pass --attach <path> to add a screenshot (max ${next_step.max_attachments}).`)
|
|
257
|
+
}
|
|
258
|
+
}).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
|
|
259
|
+
|
|
260
|
+
module.exports = { run }
|