happyskills 0.53.0 → 1.0.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 +30 -0
- package/package.json +1 -1
- package/src/api/auth.js +18 -2
- package/src/api/client.js +29 -3
- package/src/api/feedback.js +14 -5
- package/src/api/repos.js +28 -10
- package/src/api/translate.js +90 -0
- package/src/commands/delete.js +15 -1
- package/src/commands/feedback.js +2 -2
- package/src/commands/init.js +5 -1
- package/src/commands/install.js +58 -32
- package/src/commands/postlex.js +53 -35
- package/src/commands/postlex.test.js +48 -18
- package/src/commands/pull.js +5 -1
- package/src/commands/reconcile.js +52 -4
- package/src/commands/release.js +45 -15
- package/src/commands/schema.js +179 -0
- package/src/commands/search.js +34 -22
- package/src/commands/search.test.js +59 -33
- package/src/commands/uninstall.js +20 -11
- package/src/commands/validate.js +33 -11
- package/src/constants/error_codes.js +197 -0
- package/src/constants/exit_codes.js +54 -0
- package/src/constants/next_step_actions.js +133 -0
- package/src/constants/next_step_by_error_code.js +249 -0
- package/src/constants.js +2 -1
- package/src/index.js +51 -7
- package/src/integration/api_envelope.test.js +499 -0
- package/src/integration/bump.test.js +13 -4
- package/src/integration/cli.test.js +169 -147
- package/src/integration/drift.test.js +16 -4
- package/src/integration/install_fresh.test.js +37 -29
- package/src/integration/reconcile.test.js +77 -56
- package/src/integration/release.test.js +48 -31
- package/src/integration/schema.test.js +167 -0
- package/src/schema/envelope.schema.json +73 -0
- package/src/schema/envelope_test_helpers.js +94 -0
- package/src/schema/envelope_validator.js +239 -0
- package/src/schema/envelope_validator.test.js +333 -0
- package/src/ui/envelope.js +171 -0
- package/src/ui/output.js +66 -2
- package/src/utils/errors.js +116 -47
- package/src/utils/intent.js +22 -1
|
@@ -19,14 +19,21 @@ const { spawnSync } = require('child_process')
|
|
|
19
19
|
const fs = require('fs')
|
|
20
20
|
const os = require('os')
|
|
21
21
|
const path = require('path')
|
|
22
|
+
const {
|
|
23
|
+
parse_envelope,
|
|
24
|
+
assert_error_envelope,
|
|
25
|
+
assert_has_next_step,
|
|
26
|
+
} = require('../schema/envelope_test_helpers')
|
|
22
27
|
|
|
23
28
|
const CLI = path.resolve(__dirname, '../../bin/happyskills.js')
|
|
24
29
|
const NODE = process.execPath
|
|
25
30
|
|
|
26
31
|
const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-install-fresh-test-'))
|
|
27
32
|
const run = (args, opts) => {
|
|
28
|
-
const
|
|
29
|
-
|
|
33
|
+
const has_mode_flag = args.includes('--json') || args.includes('--text')
|
|
34
|
+
const final_args = has_mode_flag ? args : [...args, '--text']
|
|
35
|
+
const result = spawnSync(NODE, [CLI, ...final_args], {
|
|
36
|
+
env: { ...process.env, NO_COLOR: '1', HAPPYSKILLS_ENVELOPE_STRICT: '1', HAPPYSKILLS_API_URL: 'http://localhost:0', CI: '', ...(opts?.env || {}) },
|
|
30
37
|
encoding: 'utf-8',
|
|
31
38
|
timeout: 10000,
|
|
32
39
|
cwd: opts?.cwd
|
|
@@ -93,50 +100,51 @@ const scaffold_installed = ({ full, short, version, modified }) => {
|
|
|
93
100
|
}
|
|
94
101
|
|
|
95
102
|
describe('install --fresh — § 8.5 hardening', () => {
|
|
96
|
-
it('hard-fails with VERSION_NOT_FOUND when registry is unreachable (no silent fallback)', async () => {
|
|
103
|
+
it('hard-fails with VERSION_NOT_FOUND envelope when registry is unreachable (no silent fallback)', async () => {
|
|
97
104
|
const ctx = await scaffold_installed({ full: 'acme/test', short: 'test', version: '1.0.0' })
|
|
98
105
|
try {
|
|
99
|
-
const { code,
|
|
106
|
+
const { code, stdout } = run(
|
|
100
107
|
['install', 'acme/test@0.3.3', '--fresh', '--json', '-y'],
|
|
101
108
|
{ cwd: ctx.root }
|
|
102
109
|
)
|
|
103
|
-
// Exit code 2
|
|
104
|
-
assert.strictEqual(code, 2, '
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const parsed = JSON.parse(json_match[0])
|
|
113
|
-
assert.strictEqual(parsed.error?.code, 'USAGE_ERROR', `error.code must be USAGE_ERROR, got ${parsed.error?.code}`)
|
|
114
|
-
}
|
|
110
|
+
// Exit code 2 per spec § 5.3 mapping table (VERSION_NOT_FOUND → 2).
|
|
111
|
+
assert.strictEqual(code, 2, 'VERSION_NOT_FOUND maps to exit 2 per spec § 5.3')
|
|
112
|
+
const env = parse_envelope(stdout, 'install --fresh VERSION_NOT_FOUND')
|
|
113
|
+
assert_error_envelope(env, 'VERSION_NOT_FOUND', 'install --fresh missing version')
|
|
114
|
+
// Decision next_step: pick_version with available list + safe command.
|
|
115
|
+
assert_has_next_step(env, 'pick_version', 'install --fresh missing version')
|
|
116
|
+
assert.strictEqual(env.next_step.kind, 'decision')
|
|
117
|
+
assert.strictEqual(env.next_step.context.requested, '0.3.3')
|
|
118
|
+
assert.strictEqual(env.next_step.principal_authorization_required, true)
|
|
115
119
|
|
|
116
120
|
// Critical: disk content must be UNCHANGED — no silent overwrite.
|
|
117
121
|
assert.strictEqual(ctx.read_manifest().version, '1.0.0', 'skill.json must NOT have been overwritten')
|
|
118
122
|
} finally { ctx.cleanup() }
|
|
119
123
|
})
|
|
120
124
|
|
|
121
|
-
it('refuses --fresh when local edits are present
|
|
125
|
+
it('refuses --fresh when local edits are present, emitting LOCAL_EDITS_PRESENT + confirm_discard_or_snapshot_first', async () => {
|
|
122
126
|
const ctx = await scaffold_installed({ full: 'acme/edited', short: 'edited', version: '1.0.0', modified: true })
|
|
123
127
|
try {
|
|
124
|
-
const { code, stdout
|
|
128
|
+
const { code, stdout } = run(
|
|
125
129
|
['install', 'acme/edited@1.0.0', '--fresh', '--json', '-y'],
|
|
126
130
|
{ cwd: ctx.root }
|
|
127
131
|
)
|
|
128
|
-
// Should hard-fail at the LOCAL_EDITS_PRESENT step (before any registry call would matter).
|
|
129
132
|
assert.notStrictEqual(code, 0, 'must not succeed silently')
|
|
130
|
-
const
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
)
|
|
139
|
-
|
|
133
|
+
const env = parse_envelope(stdout, 'install --fresh LOCAL_EDITS_PRESENT')
|
|
134
|
+
// LOCAL_EDITS_PRESENT must surface BEFORE the registry-call failure
|
|
135
|
+
// path — the local-state gate is structurally earlier than the
|
|
136
|
+
// network call. Registry unreachability is irrelevant here.
|
|
137
|
+
assert_error_envelope(env, 'LOCAL_EDITS_PRESENT', 'install --fresh local edits')
|
|
138
|
+
assert_has_next_step(env, 'confirm_discard_or_snapshot_first', 'install --fresh local edits')
|
|
139
|
+
assert.strictEqual(env.next_step.kind, 'confirmation')
|
|
140
|
+
assert.strictEqual(env.next_step.principal_authorization_required, true)
|
|
141
|
+
// Commands array: SAFE default first (snapshot), destructive second.
|
|
142
|
+
const commands = env.next_step.context.commands
|
|
143
|
+
assert.ok(Array.isArray(commands) && commands.length >= 2)
|
|
144
|
+
assert.match(commands[0], /snapshot/, 'safe command (snapshot) must come first')
|
|
145
|
+
assert.match(commands[1], /--force-discard-local/, 'destructive variant second')
|
|
146
|
+
|
|
147
|
+
// The mutated content must still be on disk — confirmation gate held.
|
|
140
148
|
assert.ok(/MUTATED BODY/.test(ctx.read_skill_md()), 'local mutation must be preserved')
|
|
141
149
|
} finally { ctx.cleanup() }
|
|
142
150
|
})
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
/**
|
|
3
|
-
* Integration tests for `happyskills reconcile` — § 8.4
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
3
|
+
* Integration tests for `happyskills reconcile` — § 8.4 + envelope refactor
|
|
4
|
+
* (spec 260525-cli-default-json). Asserts on the SIX-KEY envelope shape:
|
|
5
|
+
* top-level `ok / data / error / next_step / warnings / meta`, closed enums,
|
|
6
|
+
* dependentRequired clusters. Every --json invocation is validated via the
|
|
7
|
+
* shared parse_envelope helper so this file doubles as a conformance test.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* The other invariant locked here: reconcile NO-OPS on the `ahead` state.
|
|
10
|
+
* Ahead is not drift — it must produce a success envelope with NO populated
|
|
11
|
+
* next_step (next_step === {}), routing the agent back to publish through a
|
|
12
|
+
* data.hint or similar in-data signal, never via the envelope's next_step.
|
|
12
13
|
*/
|
|
13
14
|
const { describe, it } = require('node:test')
|
|
14
15
|
const assert = require('node:assert/strict')
|
|
@@ -16,24 +17,28 @@ const { spawnSync } = require('child_process')
|
|
|
16
17
|
const fs = require('fs')
|
|
17
18
|
const os = require('os')
|
|
18
19
|
const path = require('path')
|
|
20
|
+
const {
|
|
21
|
+
parse_envelope,
|
|
22
|
+
assert_success_envelope,
|
|
23
|
+
assert_has_next_step,
|
|
24
|
+
assert_no_next_step,
|
|
25
|
+
} = require('../schema/envelope_test_helpers')
|
|
19
26
|
|
|
20
27
|
const CLI = path.resolve(__dirname, '../../bin/happyskills.js')
|
|
21
28
|
const NODE = process.execPath
|
|
22
29
|
|
|
23
30
|
const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-reconcile-test-'))
|
|
24
31
|
const run = (args, opts) => {
|
|
25
|
-
const
|
|
26
|
-
|
|
32
|
+
const has_mode_flag = args.includes('--json') || args.includes('--text')
|
|
33
|
+
const final_args = has_mode_flag ? args : [...args, '--text']
|
|
34
|
+
const result = spawnSync(NODE, [CLI, ...final_args], {
|
|
35
|
+
env: { ...process.env, NO_COLOR: '1', HAPPYSKILLS_ENVELOPE_STRICT: '1', HAPPYSKILLS_API_URL: 'http://localhost:0', CI: '', ...(opts?.env || {}) },
|
|
27
36
|
encoding: 'utf-8',
|
|
28
37
|
timeout: 10000,
|
|
29
38
|
cwd: opts?.cwd
|
|
30
39
|
})
|
|
31
40
|
return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status }
|
|
32
41
|
}
|
|
33
|
-
const parse_json = (stdout, label) => {
|
|
34
|
-
try { return JSON.parse(stdout) }
|
|
35
|
-
catch { assert.fail(`${label}: stdout is not valid JSON:\n${stdout}`) }
|
|
36
|
-
}
|
|
37
42
|
|
|
38
43
|
const scaffold = ({ full, short, lock_version, disk_version, omit_skill_json, omit_dir }) => {
|
|
39
44
|
const root = make_tmp()
|
|
@@ -77,33 +82,38 @@ const scaffold = ({ full, short, lock_version, disk_version, omit_skill_json, om
|
|
|
77
82
|
}
|
|
78
83
|
}
|
|
79
84
|
|
|
80
|
-
describe('reconcile — § 8.4', () => {
|
|
81
|
-
it('reports clean
|
|
85
|
+
describe('reconcile — envelope shape (§ 8.4 + envelope refactor)', () => {
|
|
86
|
+
it('reports clean as a success envelope with no follow-up', () => {
|
|
82
87
|
const ctx = scaffold({ full: 'acme/clean', short: 'clean', lock_version: '1.0.0', disk_version: '1.0.0' })
|
|
83
88
|
try {
|
|
84
89
|
const { code, stdout } = run(['reconcile', 'acme/clean', '--json'], { cwd: ctx.root })
|
|
85
90
|
assert.strictEqual(code, 0)
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
assert.strictEqual(
|
|
91
|
+
const env = parse_envelope(stdout, 'reconcile clean')
|
|
92
|
+
assert_success_envelope(env, 'reconcile clean')
|
|
93
|
+
assert_no_next_step(env, 'reconcile clean')
|
|
94
|
+
assert.strictEqual(env.data.no_drift, true)
|
|
95
|
+
assert.strictEqual(env.data.status, 'clean')
|
|
96
|
+
assert.strictEqual(env.meta.command, 'reconcile')
|
|
90
97
|
} finally { ctx.cleanup() }
|
|
91
98
|
})
|
|
92
99
|
|
|
93
|
-
it('NO-OPS on ahead (disk > lock) —
|
|
94
|
-
//
|
|
95
|
-
//
|
|
100
|
+
it('NO-OPS on ahead (disk > lock) — success envelope, empty next_step, hint inside data', () => {
|
|
101
|
+
// Spec § 17.7 anti-pattern guard: ahead is NOT drift. The envelope
|
|
102
|
+
// must reflect that with an empty next_step. The "go publish" hint
|
|
103
|
+
// lives inside data, never in the envelope.next_step slot (that
|
|
104
|
+
// would mis-signal a protocol followup).
|
|
96
105
|
const ctx = scaffold({ full: 'acme/ahead', short: 'ahead', lock_version: '0.3.2', disk_version: '0.3.3' })
|
|
97
106
|
try {
|
|
98
107
|
const { code, stdout } = run(['reconcile', 'acme/ahead', '--json'], { cwd: ctx.root })
|
|
99
108
|
assert.strictEqual(code, 0)
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
assert.strictEqual(
|
|
104
|
-
assert.strictEqual(
|
|
105
|
-
assert.strictEqual(
|
|
106
|
-
assert.
|
|
109
|
+
const env = parse_envelope(stdout, 'reconcile ahead')
|
|
110
|
+
assert_success_envelope(env, 'reconcile ahead')
|
|
111
|
+
assert_no_next_step(env, 'reconcile ahead')
|
|
112
|
+
assert.strictEqual(env.data.no_drift, true)
|
|
113
|
+
assert.strictEqual(env.data.status, 'ahead')
|
|
114
|
+
assert.strictEqual(env.data.ahead.lock_version, '0.3.2')
|
|
115
|
+
assert.strictEqual(env.data.ahead.disk_version, '0.3.3')
|
|
116
|
+
assert.match(env.data.hint, /publish/i, 'hint inside data should point at publish/release')
|
|
107
117
|
|
|
108
118
|
// Confirm no file mutation.
|
|
109
119
|
assert.strictEqual(ctx.read_manifest().version, '0.3.3', 'skill.json must be unchanged')
|
|
@@ -111,29 +121,35 @@ describe('reconcile — § 8.4', () => {
|
|
|
111
121
|
} finally { ctx.cleanup() }
|
|
112
122
|
})
|
|
113
123
|
|
|
114
|
-
it('emits resolve_regression next_step for disk < lock', () => {
|
|
124
|
+
it('emits resolve_regression decision next_step for disk < lock', () => {
|
|
115
125
|
const ctx = scaffold({ full: 'acme/regression', short: 'regression', lock_version: '0.4.0', disk_version: '0.3.0' })
|
|
116
126
|
try {
|
|
117
127
|
const { code, stdout } = run(['reconcile', 'acme/regression', '--json'], { cwd: ctx.root })
|
|
128
|
+
// Diagnosis-with-followup is success — exit 0, but next_step populated.
|
|
118
129
|
assert.strictEqual(code, 0)
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
assert.strictEqual(
|
|
123
|
-
assert.
|
|
124
|
-
assert.
|
|
130
|
+
const env = parse_envelope(stdout, 'reconcile regression')
|
|
131
|
+
assert_success_envelope(env, 'reconcile regression')
|
|
132
|
+
assert_has_next_step(env, 'resolve_regression', 'reconcile regression')
|
|
133
|
+
assert.strictEqual(env.next_step.kind, 'decision')
|
|
134
|
+
assert.strictEqual(env.next_step.principal_authorization_required, true)
|
|
135
|
+
assert.strictEqual(env.data.drift_state, 'regression')
|
|
136
|
+
assert.ok(env.next_step.context.options.includes('restore_from_lock_version'))
|
|
137
|
+
assert.ok(env.next_step.context.options.includes('accept_disk_as_explicit_downgrade'))
|
|
138
|
+
// Spec § 4.5.2: reconcile routes to happyskills-sync.
|
|
139
|
+
assert.strictEqual(env.next_step.route_to_skill, 'happyskills-sync')
|
|
125
140
|
} finally { ctx.cleanup() }
|
|
126
141
|
})
|
|
127
142
|
|
|
128
|
-
it('--apply restore_from_lock_version repairs a regression
|
|
143
|
+
it('--apply restore_from_lock_version repairs a regression and emits success with no follow-up', () => {
|
|
129
144
|
const ctx = scaffold({ full: 'acme/fix-regression', short: 'fix-regression', lock_version: '0.4.0', disk_version: '0.3.0' })
|
|
130
145
|
try {
|
|
131
146
|
const { code, stdout } = run(['reconcile', 'acme/fix-regression', '--apply', 'restore_from_lock_version', '--json'], { cwd: ctx.root })
|
|
132
147
|
assert.strictEqual(code, 0)
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
assert.strictEqual(
|
|
148
|
+
const env = parse_envelope(stdout, 'reconcile regression apply')
|
|
149
|
+
assert_success_envelope(env, 'reconcile apply')
|
|
150
|
+
assert_no_next_step(env, 'reconcile apply')
|
|
151
|
+
assert.strictEqual(env.data.applied.applied, 'restore_from_lock_version')
|
|
152
|
+
assert.strictEqual(env.data.applied.new_disk_version, '0.4.0')
|
|
137
153
|
|
|
138
154
|
// skill.json now matches the lock.
|
|
139
155
|
assert.strictEqual(ctx.read_manifest().version, '0.4.0')
|
|
@@ -150,12 +166,14 @@ describe('reconcile — § 8.4', () => {
|
|
|
150
166
|
try {
|
|
151
167
|
const { code, stdout } = run(['reconcile', 'acme/no-manifest', '--json'], { cwd: ctx.root })
|
|
152
168
|
assert.strictEqual(code, 0)
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
assert.
|
|
157
|
-
assert.
|
|
158
|
-
assert.ok(
|
|
169
|
+
const env = parse_envelope(stdout, 'reconcile missing-manifest')
|
|
170
|
+
assert_success_envelope(env, 'reconcile missing-manifest')
|
|
171
|
+
assert_has_next_step(env, 'resolve_missing_skill_json', 'reconcile missing-manifest')
|
|
172
|
+
assert.strictEqual(env.next_step.kind, 'decision')
|
|
173
|
+
assert.strictEqual(env.data.drift_state, 'missing_skill_json')
|
|
174
|
+
assert.ok(env.next_step.context.options.includes('restore_from_git'))
|
|
175
|
+
assert.ok(env.next_step.context.options.includes('restore_from_registry_at_lock_version'))
|
|
176
|
+
assert.ok(env.next_step.context.options.includes('abandon'))
|
|
159
177
|
} finally { ctx.cleanup() }
|
|
160
178
|
})
|
|
161
179
|
|
|
@@ -167,11 +185,13 @@ describe('reconcile — § 8.4', () => {
|
|
|
167
185
|
try {
|
|
168
186
|
const { code, stdout } = run(['reconcile', 'acme/no-dir', '--json'], { cwd: ctx.root })
|
|
169
187
|
assert.strictEqual(code, 0)
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
assert.
|
|
174
|
-
assert.
|
|
188
|
+
const env = parse_envelope(stdout, 'reconcile missing-dir')
|
|
189
|
+
assert_success_envelope(env, 'reconcile missing-dir')
|
|
190
|
+
assert_has_next_step(env, 'resolve_missing_dir', 'reconcile missing-dir')
|
|
191
|
+
assert.strictEqual(env.next_step.kind, 'decision')
|
|
192
|
+
assert.strictEqual(env.data.drift_state, 'missing_dir')
|
|
193
|
+
assert.ok(env.next_step.context.options.includes('reinstall_at_lock_version'))
|
|
194
|
+
assert.ok(env.next_step.context.options.includes('abandon'))
|
|
175
195
|
} finally { ctx.cleanup() }
|
|
176
196
|
})
|
|
177
197
|
|
|
@@ -180,9 +200,10 @@ describe('reconcile — § 8.4', () => {
|
|
|
180
200
|
try {
|
|
181
201
|
const { code, stdout } = run(['reconcile', 'bare', '--json'], { cwd: ctx.root })
|
|
182
202
|
assert.strictEqual(code, 0)
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
assert.strictEqual(
|
|
203
|
+
const env = parse_envelope(stdout, 'reconcile bare')
|
|
204
|
+
assert_success_envelope(env)
|
|
205
|
+
assert.strictEqual(env.data.skill, 'acme/bare')
|
|
206
|
+
assert.strictEqual(env.data.no_drift, true)
|
|
186
207
|
} finally { ctx.cleanup() }
|
|
187
208
|
})
|
|
188
209
|
})
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
/**
|
|
3
|
-
* Integration tests for `happyskills release` — § 8.2
|
|
3
|
+
* Integration tests for `happyskills release` — § 8.2 + envelope refactor
|
|
4
|
+
* (spec 260525-cli-default-json). Asserts on the six-key envelope shape and
|
|
5
|
+
* the closed `next_step.action` enum.
|
|
4
6
|
*
|
|
5
7
|
* Focus: the failure-mode envelopes (drift, missing_version, missing_changelog
|
|
6
8
|
* entry) and the ahead-recognition path through --dry-run. The full happy-path
|
|
7
|
-
* publish requires a real registry and is covered by the
|
|
8
|
-
*
|
|
9
|
-
*
|
|
9
|
+
* publish requires a real registry and is covered by the publish pipeline
|
|
10
|
+
* tests; here we verify orchestration, snapshot capture/restore, and the
|
|
11
|
+
* structured next_step envelopes.
|
|
10
12
|
*/
|
|
11
13
|
const { describe, it } = require('node:test')
|
|
12
14
|
const assert = require('node:assert/strict')
|
|
@@ -14,24 +16,28 @@ const { spawnSync } = require('child_process')
|
|
|
14
16
|
const fs = require('fs')
|
|
15
17
|
const os = require('os')
|
|
16
18
|
const path = require('path')
|
|
19
|
+
const {
|
|
20
|
+
parse_envelope,
|
|
21
|
+
assert_success_envelope,
|
|
22
|
+
assert_error_envelope,
|
|
23
|
+
assert_has_next_step,
|
|
24
|
+
} = require('../schema/envelope_test_helpers')
|
|
17
25
|
|
|
18
26
|
const CLI = path.resolve(__dirname, '../../bin/happyskills.js')
|
|
19
27
|
const NODE = process.execPath
|
|
20
28
|
|
|
21
29
|
const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-release-test-'))
|
|
22
30
|
const run = (args, opts) => {
|
|
23
|
-
const
|
|
24
|
-
|
|
31
|
+
const has_mode_flag = args.includes('--json') || args.includes('--text')
|
|
32
|
+
const final_args = has_mode_flag ? args : [...args, '--text']
|
|
33
|
+
const result = spawnSync(NODE, [CLI, ...final_args], {
|
|
34
|
+
env: { ...process.env, NO_COLOR: '1', HAPPYSKILLS_ENVELOPE_STRICT: '1', HAPPYSKILLS_API_URL: 'http://localhost:0', CI: '', ...(opts?.env || {}) },
|
|
25
35
|
encoding: 'utf-8',
|
|
26
36
|
timeout: 15000,
|
|
27
37
|
cwd: opts?.cwd
|
|
28
38
|
})
|
|
29
39
|
return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status }
|
|
30
40
|
}
|
|
31
|
-
const parse_json = (stdout, label) => {
|
|
32
|
-
try { return JSON.parse(stdout) }
|
|
33
|
-
catch { assert.fail(`${label}: stdout is not valid JSON:\n${stdout}`) }
|
|
34
|
-
}
|
|
35
41
|
|
|
36
42
|
const scaffold = ({ full, short, lock_version, disk_version, changelog }) => {
|
|
37
43
|
const root = make_tmp()
|
|
@@ -81,7 +87,7 @@ const scaffold = ({ full, short, lock_version, disk_version, changelog }) => {
|
|
|
81
87
|
}
|
|
82
88
|
|
|
83
89
|
describe('release — orchestration envelopes', () => {
|
|
84
|
-
it('blocks on regression drift and routes to reconcile', () => {
|
|
90
|
+
it('blocks on regression drift and routes to reconcile via recovery next_step', () => {
|
|
85
91
|
const ctx = scaffold({
|
|
86
92
|
full: 'acme/regressed', short: 'regressed',
|
|
87
93
|
lock_version: '1.0.0', disk_version: '0.9.0'
|
|
@@ -89,10 +95,15 @@ describe('release — orchestration envelopes', () => {
|
|
|
89
95
|
try {
|
|
90
96
|
const { code, stdout } = run(['release', 'regressed', '--workspace', 'acme', '--json'], { cwd: ctx.root })
|
|
91
97
|
assert.notStrictEqual(code, 0)
|
|
92
|
-
const env =
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
assert.
|
|
98
|
+
const env = parse_envelope(stdout, 'release regressed')
|
|
99
|
+
assert_error_envelope(env, 'DRIFT_DETECTED', 'release regressed')
|
|
100
|
+
assert_has_next_step(env, 'reconcile_first', 'release regressed')
|
|
101
|
+
assert.strictEqual(env.next_step.kind, 'recovery')
|
|
102
|
+
// Recovery commands array carries the verbatim follow-up command(s).
|
|
103
|
+
assert.ok(Array.isArray(env.next_step.context.commands))
|
|
104
|
+
assert.ok(env.next_step.context.commands.some(c => /reconcile/.test(c)))
|
|
105
|
+
// Drift detail propagates through error.drift.
|
|
106
|
+
assert.strictEqual(env.error.drift.reason, 'regression')
|
|
96
107
|
} finally { ctx.cleanup() }
|
|
97
108
|
})
|
|
98
109
|
|
|
@@ -105,7 +116,8 @@ describe('release — orchestration envelopes', () => {
|
|
|
105
116
|
try {
|
|
106
117
|
const { code, stdout } = run(['release', 'ahead-dry', '--workspace', 'acme', '--dry-run', '--json'], { cwd: ctx.root })
|
|
107
118
|
assert.strictEqual(code, 0, 'dry-run on ahead must succeed')
|
|
108
|
-
const env =
|
|
119
|
+
const env = parse_envelope(stdout, 'release ahead dry-run')
|
|
120
|
+
assert_success_envelope(env, 'release ahead dry-run')
|
|
109
121
|
assert.strictEqual(env.data.dry_run, true)
|
|
110
122
|
assert.strictEqual(env.data.target_version, '0.3.3')
|
|
111
123
|
assert.strictEqual(env.data.ahead_recognized, true)
|
|
@@ -115,7 +127,7 @@ describe('release — orchestration envelopes', () => {
|
|
|
115
127
|
} finally { ctx.cleanup() }
|
|
116
128
|
})
|
|
117
129
|
|
|
118
|
-
it('blocks with MISSING_VERSION on clean + --no-bump', () => {
|
|
130
|
+
it('blocks with MISSING_VERSION + specify_bump_type on clean + --no-bump', () => {
|
|
119
131
|
const ctx = scaffold({
|
|
120
132
|
full: 'acme/clean', short: 'clean',
|
|
121
133
|
lock_version: '1.0.0', disk_version: '1.0.0'
|
|
@@ -123,8 +135,11 @@ describe('release — orchestration envelopes', () => {
|
|
|
123
135
|
try {
|
|
124
136
|
const { code, stdout } = run(['release', 'clean', '--workspace', 'acme', '--no-bump', '--json'], { cwd: ctx.root })
|
|
125
137
|
assert.notStrictEqual(code, 0)
|
|
126
|
-
const env =
|
|
127
|
-
|
|
138
|
+
const env = parse_envelope(stdout, 'release clean --no-bump')
|
|
139
|
+
assert_error_envelope(env, 'MISSING_VERSION', 'release clean --no-bump')
|
|
140
|
+
assert_has_next_step(env, 'specify_bump_type', 'release missing version')
|
|
141
|
+
assert.strictEqual(env.next_step.kind, 'decision')
|
|
142
|
+
assert.deepStrictEqual(env.next_step.context.options, ['patch', 'minor', 'major'])
|
|
128
143
|
} finally { ctx.cleanup() }
|
|
129
144
|
})
|
|
130
145
|
|
|
@@ -137,13 +152,15 @@ describe('release — orchestration envelopes', () => {
|
|
|
137
152
|
try {
|
|
138
153
|
const { code, stdout } = run(['release', 'no-cl', '--bump', 'patch', '--workspace', 'acme', '--json'], { cwd: ctx.root })
|
|
139
154
|
assert.notStrictEqual(code, 0)
|
|
140
|
-
const env =
|
|
141
|
-
|
|
142
|
-
|
|
155
|
+
const env = parse_envelope(stdout, 'release no-cl')
|
|
156
|
+
assert_error_envelope(env, 'MISSING_CHANGELOG_ENTRY', 'release no-cl')
|
|
157
|
+
assert_has_next_step(env, 'provide_changelog', 'release no-cl')
|
|
158
|
+
assert.strictEqual(env.next_step.kind, 'recovery')
|
|
159
|
+
assert.strictEqual(env.next_step.principal_authorization_required, true)
|
|
143
160
|
} finally { ctx.cleanup() }
|
|
144
161
|
})
|
|
145
162
|
|
|
146
|
-
it('emits
|
|
163
|
+
it('emits BUMP_DISAGREEMENT + resolve_bump_disagreement when --bump contradicts an already-ahead disk', () => {
|
|
147
164
|
const ctx = scaffold({
|
|
148
165
|
full: 'acme/disagree', short: 'disagree',
|
|
149
166
|
lock_version: '0.1.0', disk_version: '0.5.0'
|
|
@@ -151,19 +168,19 @@ describe('release — orchestration envelopes', () => {
|
|
|
151
168
|
try {
|
|
152
169
|
const { code, stdout } = run(['release', 'disagree', '--bump', 'patch', '--workspace', 'acme', '--json'], { cwd: ctx.root })
|
|
153
170
|
assert.notStrictEqual(code, 0)
|
|
154
|
-
const env =
|
|
155
|
-
|
|
156
|
-
|
|
171
|
+
const env = parse_envelope(stdout, 'release disagree')
|
|
172
|
+
assert_error_envelope(env, 'BUMP_DISAGREEMENT', 'release disagree')
|
|
173
|
+
assert_has_next_step(env, 'resolve_bump_disagreement', 'release disagree')
|
|
174
|
+
assert.strictEqual(env.next_step.kind, 'decision')
|
|
157
175
|
assert.strictEqual(env.next_step.context.disk_version, '0.5.0')
|
|
176
|
+
assert.strictEqual(env.next_step.context.requested_bump, 'patch')
|
|
158
177
|
} finally { ctx.cleanup() }
|
|
159
178
|
})
|
|
160
179
|
|
|
161
180
|
it('§2 scenario re-test: ahead state ends in a successful dry-run with NO revert and NO drift error', () => {
|
|
162
181
|
// This is the canonical regression test for the original failure.
|
|
163
182
|
// Setup mirrors §2 exactly: lock 0.3.2, disk 0.3.3 (hand-edited bump),
|
|
164
|
-
// CHANGELOG already has the 0.3.3 entry.
|
|
165
|
-
// a real registry — but the orchestration must reach the publish
|
|
166
|
-
// step cleanly, not block on a misclassified drift error.
|
|
183
|
+
// CHANGELOG already has the 0.3.3 entry.
|
|
167
184
|
const ctx = scaffold({
|
|
168
185
|
full: 'happyskillsai/happyskills-help', short: 'happyskills-help',
|
|
169
186
|
lock_version: '0.3.2', disk_version: '0.3.3',
|
|
@@ -172,11 +189,11 @@ describe('release — orchestration envelopes', () => {
|
|
|
172
189
|
try {
|
|
173
190
|
const { code, stdout } = run(['release', 'happyskills-help', '--workspace', 'happyskillsai', '--dry-run', '--json'], { cwd: ctx.root })
|
|
174
191
|
assert.strictEqual(code, 0, '§2 scenario must NOT block — ahead is normal authoring')
|
|
175
|
-
const env =
|
|
192
|
+
const env = parse_envelope(stdout, '§2 scenario re-test')
|
|
193
|
+
assert_success_envelope(env, '§2 scenario re-test')
|
|
176
194
|
assert.strictEqual(env.data.dry_run, true)
|
|
177
195
|
assert.strictEqual(env.data.target_version, '0.3.3', 'must publish the disk version')
|
|
178
196
|
assert.strictEqual(env.data.ahead_recognized, true)
|
|
179
|
-
// Snapshot was created and (because dry-run) restored — disk must be unchanged.
|
|
180
197
|
assert.strictEqual(ctx.read_manifest().version, '0.3.3')
|
|
181
198
|
} finally { ctx.cleanup() }
|
|
182
199
|
})
|