@postman/postman-plugin 0.2.2 → 0.3.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/README.md +31 -7
- package/dist/hosts/opencode-config.js +147 -0
- package/dist/hosts/opencode.js +315 -34
- package/dist/hosts/shared.js +10 -3
- package/dist/source.js +19 -5
- package/dist/system.js +20 -1
- package/hooks/session-start-context.md +1 -1
- package/mcp.pi.json +2 -2
- package/package.json +1 -1
- package/skills/api-discovery/SKILL.md +23 -7
- package/skills/api-documentation/SKILL.md +21 -3
- package/skills/api-engineer/SKILL.md +11 -11
- package/skills/api-mocking/SKILL.md +46 -18
- package/skills/api-simulation/SKILL.md +186 -0
- package/skills/api-simulation/reference/sim_yaml_schema.md +112 -0
- package/skills/bootstrap/SKILL.md +46 -32
- package/skills/bootstrap/reference/cli_installation.md +4 -3
- package/skills/ci-integration/SKILL.md +10 -9
- package/skills/datasets/SKILL.md +6 -5
- package/skills/variables/SKILL.md +106 -0
package/README.md
CHANGED
|
@@ -85,8 +85,14 @@ Postman's MCP server with `/mcp-config login plugin-postman:postman`.
|
|
|
85
85
|
|
|
86
86
|
### OpenCode
|
|
87
87
|
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
```bash
|
|
89
|
+
opencode plugin add github:postmanlabs/postman-plugin
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
That is OpenCode 2.0.4 or later. On OpenCode 1.14.33 or later the command is
|
|
93
|
+
`opencode plugin --global github:postmanlabs/postman-plugin`.
|
|
94
|
+
[opencode/README.md](https://github.com/postmanlabs/postman-plugin/blob/main/opencode/README.md#install) has how to update and remove
|
|
95
|
+
it, and the older clone-and-loader install.
|
|
90
96
|
|
|
91
97
|
### Pi
|
|
92
98
|
|
|
@@ -168,15 +174,33 @@ The mock stays local until you choose to push and deploy it. When teammates or
|
|
|
168
174
|
external systems need access, the same mock can become a durable hosted URL
|
|
169
175
|
without rebuilding it in another tool.
|
|
170
176
|
|
|
177
|
+
### Develop against simulated dependencies
|
|
178
|
+
|
|
179
|
+
The [`api-simulation`](https://github.com/postmanlabs/postman-plugin/blob/main/skills/api-simulation/) skill is the local loop for a
|
|
180
|
+
service change: find every dependency the service calls, mock each one (reusing
|
|
181
|
+
the owning team's mock when they publish one), serve them on one port from one
|
|
182
|
+
`.sim.yaml`, run the real service against them, test, and push. It needs
|
|
183
|
+
Postman CLI 1.70.0 or later:
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
postman simulation run postman/simulations/orders-dev.sim.yaml --port 4900
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
The service under test is never mocked. It runs for real, so a passing run
|
|
190
|
+
shows the change works against what the mocks model; it doesn't replace a run
|
|
191
|
+
against the real upstreams. The simulation can also run in CI, and any
|
|
192
|
+
dependency can take injected latency, error, rate-limit, or chaos conditions to
|
|
193
|
+
exercise failure paths.
|
|
194
|
+
|
|
171
195
|
|
|
172
196
|
## How Postman builds context
|
|
173
197
|
|
|
174
198
|
Work done in a Postman folder produces artifacts: collection runs, lint
|
|
175
199
|
results, request history, mock deployments. When that folder is connected to a
|
|
176
|
-
Postman workspace
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
did.
|
|
200
|
+
Postman workspace after `postman init`, those artifacts are published to it as
|
|
201
|
+
they are produced. The workspace persists that context for future work and
|
|
202
|
+
enables teammates, reviewers, and other agents to share and build on what the
|
|
203
|
+
agent did.
|
|
180
204
|
|
|
181
205
|
What each command contributes to the connected workspace:
|
|
182
206
|
|
|
@@ -185,7 +209,7 @@ What each command contributes to the connected workspace:
|
|
|
185
209
|
| `postman collection run` | Run results and run history |
|
|
186
210
|
| `postman application test` | Test run results |
|
|
187
211
|
| `postman spec lint` | Lint results, including violation counts and pass/fail |
|
|
188
|
-
| `postman workspace push` |
|
|
212
|
+
| `postman workspace push` | Local workspace contents, persisted as context for future work and sharing with others |
|
|
189
213
|
| `postman runner start` | Runner processing and health activity |
|
|
190
214
|
| `postman flows run` | Flow run results |
|
|
191
215
|
| `postman request` | Agent's API interaction activity |
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// OpenCode's config is JSONC: comments and trailing commas are allowed, and users edit it by hand.
|
|
2
|
+
// Reading it and removing one array entry from it must therefore leave everything else as written.
|
|
3
|
+
const PUNCTUATION = '{}[]:,', WHITESPACE = /\s/, WHITESPACE_ONLY = /^\s*$/;
|
|
4
|
+
function tokenize(text) {
|
|
5
|
+
const tokens = [];
|
|
6
|
+
let index = text.charCodeAt(0) === 0xFEFF ? 1 : 0;
|
|
7
|
+
while (index < text.length) {
|
|
8
|
+
const char = text[index];
|
|
9
|
+
if (WHITESPACE.test(char)) {
|
|
10
|
+
index += 1;
|
|
11
|
+
}
|
|
12
|
+
else if (text.startsWith('//', index)) {
|
|
13
|
+
// A line comment ends at either line terminator; a CR-only file has no \n at all.
|
|
14
|
+
const lineEnd = text.slice(index).search(/[\r\n]/);
|
|
15
|
+
index = lineEnd === -1 ? text.length : index + lineEnd + 1;
|
|
16
|
+
}
|
|
17
|
+
else if (text.startsWith('/*', index)) {
|
|
18
|
+
const close = text.indexOf('*/', index + 2);
|
|
19
|
+
if (close === -1) {
|
|
20
|
+
// An unterminated comment is not valid JSONC; keep it as a token JSON.parse will reject.
|
|
21
|
+
tokens.push({ kind: 'other', start: index, end: text.length, text: text.slice(index) });
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
index = close + 2;
|
|
25
|
+
}
|
|
26
|
+
else if (char === '"') {
|
|
27
|
+
let end = index + 1;
|
|
28
|
+
while (end < text.length && text[end] !== '"') {
|
|
29
|
+
end += text[end] === '\\' ? 2 : 1;
|
|
30
|
+
}
|
|
31
|
+
tokens.push({ kind: 'string', start: index, end: end + 1, text: text.slice(index, end + 1) });
|
|
32
|
+
index = end + 1;
|
|
33
|
+
}
|
|
34
|
+
else if (PUNCTUATION.includes(char)) {
|
|
35
|
+
tokens.push({ kind: 'punct', start: index, end: index + 1, text: char });
|
|
36
|
+
index += 1;
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
let end = index;
|
|
40
|
+
while (end < text.length && !WHITESPACE.test(text[end]) && !PUNCTUATION.includes(text[end]) && text[end] !== '"' && !text.startsWith('//', end) && !text.startsWith('/*', end)) {
|
|
41
|
+
end += 1;
|
|
42
|
+
}
|
|
43
|
+
tokens.push({ kind: 'other', start: index, end, text: text.slice(index, end) });
|
|
44
|
+
index = end;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return tokens;
|
|
48
|
+
}
|
|
49
|
+
/** Parses JSONC; `null` when it is not valid even after comments and trailing commas are dropped. */
|
|
50
|
+
export function parseJsonc(text) {
|
|
51
|
+
const tokens = tokenize(text);
|
|
52
|
+
let json = '';
|
|
53
|
+
tokens.forEach((token, at) => {
|
|
54
|
+
const next = tokens[at + 1];
|
|
55
|
+
if (token.text === ',' && next && (next.text === ']' || next.text === '}')) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
json += `${token.text} `;
|
|
59
|
+
});
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(json || 'null');
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The direct string elements of the root object's array under `key`, or `null` when there is no such array or the root
|
|
69
|
+
* object has `key` more than once: JSON.parse keeps the last one, so editing any of them could leave the one that counts.
|
|
70
|
+
*/
|
|
71
|
+
function stringElements(tokens, key) {
|
|
72
|
+
let depth = 0, found = null, occurrences = 0;
|
|
73
|
+
for (let at = 0; at < tokens.length; at += 1) {
|
|
74
|
+
const token = tokens[at];
|
|
75
|
+
if (token.kind === 'punct' && '{['.includes(token.text)) {
|
|
76
|
+
depth += 1;
|
|
77
|
+
}
|
|
78
|
+
else if (token.kind === 'punct' && '}]'.includes(token.text)) {
|
|
79
|
+
depth -= 1;
|
|
80
|
+
}
|
|
81
|
+
else if (depth === 1 && token.kind === 'string' && tokens[at + 1]?.text === ':') {
|
|
82
|
+
let value;
|
|
83
|
+
try {
|
|
84
|
+
value = JSON.parse(token.text);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (value !== key) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
occurrences += 1;
|
|
93
|
+
if (tokens[at + 2]?.text !== '[') {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const elements = [];
|
|
97
|
+
let nested = 1;
|
|
98
|
+
for (let inner = at + 3; inner < tokens.length && nested > 0; inner += 1) {
|
|
99
|
+
const item = tokens[inner];
|
|
100
|
+
if (item.kind === 'punct' && '{['.includes(item.text)) {
|
|
101
|
+
nested += 1;
|
|
102
|
+
}
|
|
103
|
+
else if (item.kind === 'punct' && '}]'.includes(item.text)) {
|
|
104
|
+
nested -= 1;
|
|
105
|
+
}
|
|
106
|
+
else if (nested === 1 && item.kind === 'string') {
|
|
107
|
+
elements.push({
|
|
108
|
+
start: item.start,
|
|
109
|
+
end: item.end,
|
|
110
|
+
value: JSON.parse(item.text),
|
|
111
|
+
before: tokens[inner - 1].text === ',' ? tokens[inner - 1] : null,
|
|
112
|
+
after: tokens[inner + 1]?.text === ',' ? tokens[inner + 1] : null
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
found ??= elements;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return occurrences === 1 ? found : null;
|
|
120
|
+
}
|
|
121
|
+
/** `text` without the first `value` in the array under `key`, comments and layout intact; `null` if it is not there or the result would not parse. */
|
|
122
|
+
export function withoutArrayString(text, key, value) {
|
|
123
|
+
const element = stringElements(tokenize(text), key)?.find((entry) => entry.value === value);
|
|
124
|
+
if (!element) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
let start = element.start, end = element.end;
|
|
128
|
+
if (element.after) {
|
|
129
|
+
end = element.after.end;
|
|
130
|
+
while (WHITESPACE.test(text[end] ?? '')) {
|
|
131
|
+
end += 1;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
else if (element.before && WHITESPACE_ONLY.test(text.slice(element.before.end, element.start))) {
|
|
135
|
+
start = element.before.start;
|
|
136
|
+
}
|
|
137
|
+
// A comment between the comma and the last element belongs to the element before it, so only the comma goes.
|
|
138
|
+
else if (element.before) {
|
|
139
|
+
while (/[ \t]/.test(text[start - 1] ?? '')) {
|
|
140
|
+
start -= 1;
|
|
141
|
+
}
|
|
142
|
+
const edited = text.slice(0, element.before.start) + text.slice(element.before.end, start) + text.slice(end);
|
|
143
|
+
return parseJsonc(edited) === null ? null : edited;
|
|
144
|
+
}
|
|
145
|
+
const edited = text.slice(0, start) + text.slice(end);
|
|
146
|
+
return parseJsonc(edited) === null ? null : edited;
|
|
147
|
+
}
|
package/dist/hosts/opencode.js
CHANGED
|
@@ -1,12 +1,262 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import path from 'node:path';
|
|
2
|
-
import { OPENCODE_SHIM } from '../source.js';
|
|
3
|
-
import {
|
|
3
|
+
import { OPENCODE_MINIMUM, OPENCODE_SHIM, OPENCODE_SPEC, REPO, isSameRepo, redact } from '../source.js';
|
|
4
|
+
import { parseJsonc, withoutArrayString } from './opencode-config.js';
|
|
5
|
+
import { assertCloneRemovable, blocked, failed, guard, mustRun, removeClone } from './shared.js';
|
|
4
6
|
import { result } from './types.js';
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
const
|
|
8
|
-
//
|
|
9
|
-
|
|
7
|
+
// OpenCode 1 lists its plugins under `plugin`, OpenCode 2 under `plugins`. OpenCode 2's `plugin add` and `plugin remove`
|
|
8
|
+
// edit the first of these that exists in the config directory (`resolveConfigPath` in its packages/cli).
|
|
9
|
+
const KEYS = ['plugin', 'plugins'], CONFIG_FILES = ['opencode.json', 'opencode.jsonc', path.join('.opencode', 'opencode.json'), path.join('.opencode', 'opencode.jsonc')], samePath = (a, b) => path.relative(a, b) === '', NEXT = 'Restart OpenCode for the change to take effect.',
|
|
10
|
+
// Installs another spec instead, such as a copy of a change before it merges.
|
|
11
|
+
SPEC_VARIABLE = 'POSTMAN_PLUGIN_OPENCODE_SPEC', configDir = (system) => path.join(system.env.XDG_CONFIG_HOME || path.join(system.home, '.config'), 'opencode'),
|
|
12
|
+
// OpenCode 2 reads only OPENCODE_CONFIG_DIR when it is set (`Global` in its packages/util/src/global.ts); OpenCode 1
|
|
13
|
+
// reads it as well as the default directory. `major` is null where an entry anywhere matters, as for remove.
|
|
14
|
+
configDirs = (system, major) => (major !== null && major >= 2 && system.env.OPENCODE_CONFIG_DIR ?
|
|
15
|
+
[system.env.OPENCODE_CONFIG_DIR] :
|
|
16
|
+
[system.env.OPENCODE_CONFIG_DIR, configDir(system)])
|
|
17
|
+
.filter((dir) => Boolean(dir))
|
|
18
|
+
.filter((dir, at, dirs) => dirs.findIndex((other) => samePath(other, dir)) === at),
|
|
19
|
+
// OpenCode 2 keeps one cached copy of each git plugin here, named `git-<slug>-<first 12 hex of sha256(spec)>`.
|
|
20
|
+
cachedCopy = (system, spec) => path.join(system.env.XDG_CACHE_HOME || path.join(system.home, '.cache'), 'opencode', 'npm', `git-${gitSlug(spec)}-${createHash('sha256').update(spec).digest('hex').slice(0, 12)}`),
|
|
21
|
+
// `plugin add` and `plugin remove` act on OPENCODE_CONFIG_DIR when it is set, wherever our entry was found.
|
|
22
|
+
// Compared as paths, not strings: Windows spells the same directory with either slash, in any case.
|
|
23
|
+
inConfigOf = (system, entry) => (system.env.OPENCODE_CONFIG_DIR && !samePath(system.env.OPENCODE_CONFIG_DIR, entry.dir) ?
|
|
24
|
+
{ env: { OPENCODE_CONFIG_DIR: entry.dir } } :
|
|
25
|
+
undefined),
|
|
26
|
+
// The older install: a clone of this repo next to a one-line loader file that imports it.
|
|
27
|
+
cloneDir = (system) => path.join(configDir(system), 'postman-plugin'), shimFile = (system) => path.join(configDir(system), 'plugins', 'postman.ts'), shimTarget = (system) => path.join(cloneDir(system), 'opencode', 'src', 'index.ts');
|
|
28
|
+
/** OpenCode 2's name for a git spec's cache: its last path segment, as `gitSlug` in packages/util/src/npm.ts derives it. */
|
|
29
|
+
function gitSlug(spec) {
|
|
30
|
+
let target = spec.split('#')[0];
|
|
31
|
+
try {
|
|
32
|
+
target = decodeURIComponent(target);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// OpenCode falls back to the undecoded spec too.
|
|
36
|
+
}
|
|
37
|
+
return target.replace(/\.git$/i, '').split(/[/:\\]/).at(-1)?.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'repository';
|
|
38
|
+
}
|
|
39
|
+
/** What this run installs: `OPENCODE_SPEC`, or the spec POSTMAN_PLUGIN_OPENCODE_SPEC names. */
|
|
40
|
+
const specToInstall = (system) => system.env[SPEC_VARIABLE] || OPENCODE_SPEC;
|
|
41
|
+
/** The plugin's repository through `github:` or a git URL at any ref or range, or the spec POSTMAN_PLUGIN_OPENCODE_SPEC installs. */
|
|
42
|
+
function isOurSpec(system, spec) {
|
|
43
|
+
const bare = spec.split('#')[0];
|
|
44
|
+
if (spec === system.env[SPEC_VARIABLE]) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
return bare.startsWith('github:') ? isSameRepo(bare.slice('github:'.length), REPO) : isSameRepo(bare.replace(/^git\+/, ''), REPO);
|
|
48
|
+
}
|
|
49
|
+
/** True when the dotted version `found` precedes `minimum`. */
|
|
50
|
+
function isBefore(found, minimum) {
|
|
51
|
+
const differing = found.findIndex((part, at) => part !== minimum[at]);
|
|
52
|
+
return differing !== -1 && found[differing] < minimum[differing];
|
|
53
|
+
}
|
|
54
|
+
/** The release `opencode` is, refusing one older than the first of its major that installs Postman with `opencode plugin`. */
|
|
55
|
+
async function installableVersion(system) {
|
|
56
|
+
const probe = await system.probe('opencode', ['--version']), match = probe.stdout.match(/(\d+)\.(\d+)\.(\d+)/);
|
|
57
|
+
if (probe.code !== 0 || !match) {
|
|
58
|
+
return failed('could not read the version `opencode --version` prints');
|
|
59
|
+
}
|
|
60
|
+
const found = match.slice(1).map(Number), major = found[0], minimum = OPENCODE_MINIMUM[major], older = minimum !== undefined && isBefore(found, minimum);
|
|
61
|
+
if (major < 1 || older) {
|
|
62
|
+
return blocked(`OpenCode ${match[0]} is older than ${(minimum ?? OPENCODE_MINIMUM[1]).join('.')}, the first ${major}.x release that installs Postman with \`opencode plugin\`; update OpenCode and re-run`);
|
|
63
|
+
}
|
|
64
|
+
return { major, text: match[0] };
|
|
65
|
+
}
|
|
66
|
+
/** Our entries in OpenCode's global config, and the config files that could not be read. */
|
|
67
|
+
async function configured(system, major) {
|
|
68
|
+
const entries = [], unreadable = [], jsoncLists = new Set();
|
|
69
|
+
// OpenCode 1 reads only the files directly in a config directory, never its `.opencode/` copies.
|
|
70
|
+
const names = major !== null && major < 2 ? CONFIG_FILES.slice(0, 2) : CONFIG_FILES;
|
|
71
|
+
for (const [dir, file] of configDirs(system, major).flatMap((dir) => names.map((name) => [dir, path.join(dir, name)]))) {
|
|
72
|
+
const text = await system.readFile(file);
|
|
73
|
+
if (text === null) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const config = parseJsonc(text);
|
|
77
|
+
if (!config || typeof config !== 'object') {
|
|
78
|
+
unreadable.push(file);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
for (const key of KEYS) {
|
|
82
|
+
const list = config[key];
|
|
83
|
+
for (const item of Array.isArray(list) ? list : []) {
|
|
84
|
+
const form = typeof item === 'string' ? 'string' : Array.isArray(item) ? 'tuple' : 'object', spec = form === 'string' ? item : form === 'tuple' ? item[0] : item?.package;
|
|
85
|
+
if (typeof spec === 'string' && isOurSpec(system, spec)) {
|
|
86
|
+
entries.push({ dir, file, key, spec, form, shadowed: false });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (samePath(file, path.join(dir, CONFIG_FILES[1])) && Array.isArray(config.plugin)) {
|
|
91
|
+
jsoncLists.add(dir);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// OpenCode 1 merges its global opencode.json and then opencode.jsonc with a merge that replaces arrays.
|
|
95
|
+
for (const entry of entries) {
|
|
96
|
+
entry.shadowed = entry.key === 'plugin' && samePath(entry.file, path.join(entry.dir, CONFIG_FILES[0])) && jsoncLists.has(entry.dir);
|
|
97
|
+
}
|
|
98
|
+
return { entries, unreadable };
|
|
99
|
+
}
|
|
100
|
+
async function readableEntries(system, major) {
|
|
101
|
+
const { entries, unreadable } = await configured(system, major);
|
|
102
|
+
return unreadable.length ? failed(`${unreadable[0]} is not valid JSON; fix it and re-run`) : entries;
|
|
103
|
+
}
|
|
104
|
+
async function legacyState(system) {
|
|
105
|
+
const cloned = await system.exists(cloneDir(system)), shim = await system.readFile(shimFile(system));
|
|
106
|
+
return { cloned, shim, loadable: cloned && await system.exists(shimTarget(system)) };
|
|
107
|
+
}
|
|
108
|
+
/** Throws `blocked` when the older install cannot be deleted whole, changing nothing. */
|
|
109
|
+
async function preflightLegacy(system) {
|
|
110
|
+
const { cloned, shim } = await legacyState(system);
|
|
111
|
+
// A loader we didn't write may still import the clone; deleting it would break that file.
|
|
112
|
+
if (cloned && shim !== null && shim !== OPENCODE_SHIM) {
|
|
113
|
+
blocked(`${shimFile(system)} has other contents and may load ${cloneDir(system)}; move it aside and re-run`);
|
|
114
|
+
}
|
|
115
|
+
await assertCloneRemovable(system, cloneDir(system));
|
|
116
|
+
}
|
|
117
|
+
/** Deletes the clone and loader of the older install, refusing anything that is not ours. */
|
|
118
|
+
async function removeLegacy(system) {
|
|
119
|
+
const { cloned, shim } = await legacyState(system);
|
|
120
|
+
if (!cloned && shim !== OPENCODE_SHIM) {
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
// A loader we didn't write may still import the clone; deleting it would break that file.
|
|
124
|
+
if (shim !== null && shim !== OPENCODE_SHIM) {
|
|
125
|
+
blocked(`${shimFile(system)} has other contents and may load ${cloneDir(system)}; move it aside and re-run`);
|
|
126
|
+
}
|
|
127
|
+
const removed = cloned ? [cloneDir(system)] : [];
|
|
128
|
+
// The clone is checked before anything is deleted, so a refusal leaves both in place.
|
|
129
|
+
await removeClone(system, cloneDir(system));
|
|
130
|
+
if (shim === OPENCODE_SHIM) {
|
|
131
|
+
await system.remove(shimFile(system));
|
|
132
|
+
removed.push(shimFile(system));
|
|
133
|
+
}
|
|
134
|
+
return removed;
|
|
135
|
+
}
|
|
136
|
+
/** The file OpenCode 2's `plugin add` and `plugin remove` edit in `dir`; `remove` exits 0 when the entry is in another one. */
|
|
137
|
+
async function editedFile(system, dir) {
|
|
138
|
+
for (const name of CONFIG_FILES) {
|
|
139
|
+
if (await system.exists(path.join(dir, name))) {
|
|
140
|
+
return path.join(dir, name);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return path.join(dir, CONFIG_FILES[0]);
|
|
144
|
+
}
|
|
145
|
+
/** The major of the `opencode` on PATH, or null when it can't say; only OpenCode 2 has `plugin remove`. */
|
|
146
|
+
async function cliMajor(system) {
|
|
147
|
+
const probe = await system.probe('opencode', ['--version']), match = probe.code === 0 ? probe.stdout.match(/(\d+)\.\d+\.\d+/) : null;
|
|
148
|
+
return match ? Number(match[1]) : null;
|
|
149
|
+
}
|
|
150
|
+
async function removedByOpenCode(system, entry) {
|
|
151
|
+
return entry.key === 'plugins' && entry.form !== 'tuple' && samePath(entry.file, await editedFile(system, entry.dir));
|
|
152
|
+
}
|
|
153
|
+
/** The config without the entry, for an entry OpenCode has no command to remove; refuses one it can't take out cleanly. */
|
|
154
|
+
async function withoutEntry(system, entry) {
|
|
155
|
+
const text = await system.readFile(entry.file), edited = text === null || entry.form !== 'string' ? null : withoutArrayString(text, entry.key, entry.spec);
|
|
156
|
+
return edited ?? blocked(`could not remove ${redact(entry.spec)} from ${entry.file} without rewriting it; delete that entry yourself and re-run`);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* The file OpenCode's add writes. OpenCode 1's `plugin --global` writes its default config directory, whatever
|
|
160
|
+
* OPENCODE_CONFIG_DIR says, and there the first of opencode.json and opencode.jsonc that exists (`patchOne` in its
|
|
161
|
+
* plugin/install.ts).
|
|
162
|
+
*/
|
|
163
|
+
async function destination(system, version, entry) {
|
|
164
|
+
if (version.major >= 2) {
|
|
165
|
+
return editedFile(system, entry.dir);
|
|
166
|
+
}
|
|
167
|
+
const json = path.join(configDir(system), CONFIG_FILES[0]), jsonc = path.join(configDir(system), CONFIG_FILES[1]);
|
|
168
|
+
return (await system.exists(json)) || !(await system.exists(jsonc)) ? json : jsonc;
|
|
169
|
+
}
|
|
170
|
+
/** OpenCode 1 adds to opencode.json, but ignores that file's list when the opencode.jsonc beside it has one of its own. */
|
|
171
|
+
async function assertAddIsRead(system, version) {
|
|
172
|
+
const jsonc = path.join(configDir(system), CONFIG_FILES[1]);
|
|
173
|
+
if (version.major >= 2 || !(await system.exists(path.join(configDir(system), CONFIG_FILES[0])))) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const config = parseJsonc((await system.readFile(jsonc)) ?? '{}');
|
|
177
|
+
if (Array.isArray(config?.plugin)) {
|
|
178
|
+
blocked(`OpenCode 1 would add Postman to ${path.join(configDir(system), CONFIG_FILES[0])}, but reads only the \`plugin\` list in ${jsonc}; add \`${redact(specToInstall(system))}\` to that list yourself, or merge the two files, and re-run`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/** Refuses an entry OpenCode's add would not write back to: it would register a second copy elsewhere instead. */
|
|
182
|
+
async function assertLands(system, version, entry) {
|
|
183
|
+
const target = await destination(system, version, entry);
|
|
184
|
+
if (!samePath(entry.file, target)) {
|
|
185
|
+
blocked(`${entry.file} registers ${redact(entry.spec)}, but OpenCode would write to ${target}; move the entry, with any options, there and re-run`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/** Refetches a git spec the entry already names. */
|
|
189
|
+
async function refreshInPlace(system, version, entry) {
|
|
190
|
+
await assertLands(system, version, entry);
|
|
191
|
+
await addFresh(system, version, entry.spec, inConfigOf(system, entry), true);
|
|
192
|
+
}
|
|
193
|
+
/** Adds `spec`, then takes out the entry it supersedes; a refusal to take it out comes before anything is added. */
|
|
194
|
+
async function replaceEntry(system, version, entry, spec) {
|
|
195
|
+
// OpenCode 1 would add the new spec to another file and leave the old file's list overriding it.
|
|
196
|
+
if (version.major < 2) {
|
|
197
|
+
await assertLands(system, version, entry);
|
|
198
|
+
}
|
|
199
|
+
const byOpenCode = version.major >= 2 && await removedByOpenCode(system, entry);
|
|
200
|
+
if (!byOpenCode) {
|
|
201
|
+
await withoutEntry(system, entry);
|
|
202
|
+
}
|
|
203
|
+
// OpenCode 1 replaces an entry for the same package in place only when forced; unforced, it changes nothing.
|
|
204
|
+
await addFresh(system, version, spec, inConfigOf(system, entry), true);
|
|
205
|
+
// Either major may have replaced it in place already.
|
|
206
|
+
if (!(await configured(system, version.major)).entries.some((left) => left.spec === entry.spec && left.key === entry.key && samePath(left.file, entry.file))) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (byOpenCode) {
|
|
210
|
+
await mustRun(system, 'opencode', ['plugin', 'remove', entry.spec], inConfigOf(system, entry));
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
await system.writeFile(entry.file, await withoutEntry(system, entry));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/** OpenCode 2 fetches into `.staging-*` and renames it to a numeric generation only once the fetch succeeds (packages/util/src/npm.ts). */
|
|
217
|
+
async function isComplete(system, cache) {
|
|
218
|
+
return (await system.readDir(cache)).some((entry) => /^\d+$/.test(entry));
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* OpenCode 2 caches a git plugin by its spec and `plugin add` reuses the cache, even one an earlier
|
|
222
|
+
* `plugin remove` left behind, so a newer commit only arrives when the cached copy is gone; `plugin update`
|
|
223
|
+
* would do it but needs OpenCode's background service, which a second instance or a cold start answers
|
|
224
|
+
* wrongly. The copy is moved aside, not deleted, so a failed fetch leaves the plugin as it was. OpenCode 1
|
|
225
|
+
* re-resolves a git spec on every add; `force` makes it replace an entry for the same package in place.
|
|
226
|
+
*/
|
|
227
|
+
async function addFresh(system, version, spec, options, force = false) {
|
|
228
|
+
if (version.major < 2) {
|
|
229
|
+
await mustRun(system, 'opencode', ['plugin', '--global', ...(force ? ['--force'] : []), spec], options);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const copy = cachedCopy(system, spec), previous = `${copy}.previous`;
|
|
233
|
+
// A run stopped after setting the copy aside left the working one only under `.previous`, beside nothing or an unfinished fetch.
|
|
234
|
+
if (!(await isComplete(system, copy)) && await isComplete(system, previous)) {
|
|
235
|
+
await system.remove(copy);
|
|
236
|
+
await system.rename(previous, copy);
|
|
237
|
+
}
|
|
238
|
+
const cached = await isComplete(system, copy);
|
|
239
|
+
if (cached) {
|
|
240
|
+
await system.remove(previous);
|
|
241
|
+
await system.rename(copy, previous);
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
await mustRun(system, 'opencode', ['plugin', 'add', spec], options);
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
if (cached) {
|
|
248
|
+
await system.remove(copy);
|
|
249
|
+
await system.rename(previous, copy);
|
|
250
|
+
}
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
if (cached) {
|
|
254
|
+
await system.remove(previous);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function summary(action, spec, removed) {
|
|
258
|
+
return `${action} ${spec}${removed.length ? `; removed the older install at ${removed.join(' and ')}` : ''}`;
|
|
259
|
+
}
|
|
10
260
|
export const opencode = {
|
|
11
261
|
id: 'opencode',
|
|
12
262
|
name: 'OpenCode',
|
|
@@ -15,47 +265,78 @@ export const opencode = {
|
|
|
15
265
|
return (await system.which('opencode')) !== null;
|
|
16
266
|
},
|
|
17
267
|
async status(system) {
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const notes = [
|
|
23
|
-
...(shim !== null && shim !== OPENCODE_SHIM ? [`${shimFile(system)} has other contents; install will refuse to overwrite it`] : []),
|
|
24
|
-
...(cloned && !loadable ? [`${cloneDir(system)} exists but has no opencode/src/index.ts for the loader to import`] : []),
|
|
25
|
-
...(loadable && shim === null ? [`${cloneDir(system)} exists but nothing loads it`] : [])
|
|
268
|
+
const { entries, unreadable } = await configured(system, await cliMajor(system)), { cloned, shim, loadable } = await legacyState(system), legacy = loadable && shim === OPENCODE_SHIM, notes = [
|
|
269
|
+
...(legacy ? [`${cloneDir(system)} loads the same skills and will be removed`] : []),
|
|
270
|
+
...(shim !== null && shim !== OPENCODE_SHIM ? [`${shimFile(system)} has other contents; install will refuse to remove it`] : []),
|
|
271
|
+
...(cloned && !loadable ? [`${cloneDir(system)} exists but has no opencode/src/index.ts for a loader to import`] : [])
|
|
26
272
|
];
|
|
27
|
-
|
|
273
|
+
const read = entries.filter((entry) => !entry.shadowed), shadowed = entries.filter((entry) => entry.shadowed).map((entry) => `${entry.file} registers ${redact(entry.spec)}, but OpenCode 1 reads the opencode.jsonc beside it instead`);
|
|
274
|
+
if (read.length) {
|
|
275
|
+
return { installed: true, detail: `${redact(read[0].spec)} in ${read[0].file}`, notes: [...shadowed, ...notes] };
|
|
276
|
+
}
|
|
277
|
+
if (shadowed.length) {
|
|
278
|
+
return { installed: false, detail: 'not loaded', notes: [...shadowed, ...notes] };
|
|
279
|
+
}
|
|
280
|
+
if (unreadable.length) {
|
|
281
|
+
return { installed: null, detail: `could not read ${unreadable[0]}`, notes };
|
|
282
|
+
}
|
|
283
|
+
return legacy ?
|
|
284
|
+
{ installed: true, detail: `clone at ${cloneDir(system)}`, notes } :
|
|
285
|
+
{ installed: false, detail: 'not installed', notes };
|
|
28
286
|
},
|
|
29
287
|
install(system) {
|
|
30
288
|
return guard(async () => {
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
289
|
+
const version = await installableVersion(system), entries = await readableEntries(system, version.major), spec = specToInstall(system), entry = entries[0];
|
|
290
|
+
// Each would load the same skills, and refreshing one would leave the others loading the rest.
|
|
291
|
+
if (entries.length > 1) {
|
|
292
|
+
blocked(`found ${entries.length} entries for Postman (${entries.map((found) => `${redact(found.spec)} in ${found.file}`).join(', ')}); keep one, with any options, and re-run`);
|
|
293
|
+
}
|
|
294
|
+
if (entry?.shadowed) {
|
|
295
|
+
blocked(`${entry.file} registers ${redact(entry.spec)}, but OpenCode 1 reads only the \`plugin\` list in the opencode.jsonc beside it; move the entry there and re-run`);
|
|
296
|
+
}
|
|
297
|
+
// Both copies would load the same skills, so an older install that cannot be deleted whole stops us before the new one goes in.
|
|
298
|
+
await preflightLegacy(system);
|
|
299
|
+
if (!entry) {
|
|
300
|
+
await assertAddIsRead(system, version);
|
|
301
|
+
await addFresh(system, version, spec);
|
|
302
|
+
return result('done', summary('installed', redact(spec), await removeLegacy(system)), NEXT);
|
|
303
|
+
}
|
|
304
|
+
// An entry under the other major's list is moved over: refreshing it would add a second one under this major's.
|
|
305
|
+
const refresh = entry.spec === spec && entry.key === (version.major >= 2 ? 'plugins' : 'plugin');
|
|
306
|
+
if (refresh) {
|
|
307
|
+
await refreshInPlace(system, version, entry);
|
|
34
308
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
await system.writeFile(shimFile(system), OPENCODE_SHIM);
|
|
309
|
+
else {
|
|
310
|
+
await replaceEntry(system, version, entry, spec);
|
|
38
311
|
}
|
|
39
|
-
return result('done',
|
|
312
|
+
return result('done', summary(refresh ? 'updated' : `replaced ${redact(entry.spec)} under \`${entry.key}\` with`, redact(spec), await removeLegacy(system)), NEXT);
|
|
40
313
|
});
|
|
41
314
|
},
|
|
42
315
|
remove(system) {
|
|
43
316
|
return guard(async () => {
|
|
44
|
-
const
|
|
45
|
-
if (!cloned && shim !== OPENCODE_SHIM) {
|
|
317
|
+
const entries = await readableEntries(system, null), { cloned, shim } = await legacyState(system);
|
|
318
|
+
if (!entries.length && !cloned && shim !== OPENCODE_SHIM) {
|
|
46
319
|
return result('skipped', 'not installed');
|
|
47
320
|
}
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
321
|
+
// Everything that can refuse is checked before anything is removed, so a refusal leaves no half-removed state.
|
|
322
|
+
await preflightLegacy(system);
|
|
323
|
+
const hasRemove = ((await cliMajor(system)) ?? 0) >= 2, byOpenCode = await Promise.all(entries.map(async (entry) => hasRemove && removedByOpenCode(system, entry)));
|
|
324
|
+
for (const [at, entry] of entries.entries()) {
|
|
325
|
+
if (!byOpenCode[at]) {
|
|
326
|
+
await withoutEntry(system, entry);
|
|
327
|
+
}
|
|
51
328
|
}
|
|
52
|
-
const removed =
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
329
|
+
const removed = [];
|
|
330
|
+
for (const [at, entry] of entries.entries()) {
|
|
331
|
+
if (byOpenCode[at]) {
|
|
332
|
+
await mustRun(system, 'opencode', ['plugin', 'remove', entry.spec], inConfigOf(system, entry));
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
await system.writeFile(entry.file, await withoutEntry(system, entry));
|
|
336
|
+
}
|
|
337
|
+
removed.push(redact(entry.spec));
|
|
58
338
|
}
|
|
339
|
+
removed.push(...await removeLegacy(system));
|
|
59
340
|
return result('done', `removed ${removed.join(' and ')}`, NEXT);
|
|
60
341
|
});
|
|
61
342
|
}
|
package/dist/hosts/shared.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import { BRANCH, GIT_URL, REPO, isSameRepo, redact } from '../source.js';
|
|
2
|
+
import { BRANCH, GIT_URL, REPO, isSameRepo, redact, redactText } from '../source.js';
|
|
3
3
|
import { formatCommand } from '../system.js';
|
|
4
4
|
import { result } from './types.js';
|
|
5
5
|
export class StepFailed extends Error {
|
|
@@ -21,7 +21,7 @@ function lastLines(text, count = 5) {
|
|
|
21
21
|
return text.trim().split('\n').slice(-count).join('\n');
|
|
22
22
|
}
|
|
23
23
|
function describeFailure(command, args, exec) {
|
|
24
|
-
const output = lastLines(exec.stderr) || lastLines(exec.stdout);
|
|
24
|
+
const output = redactText(lastLines(exec.stderr) || lastLines(exec.stdout));
|
|
25
25
|
return `\`${formatCommand(command, args)}\` exited ${exec.code}${output ? `\n${output}` : ''}`;
|
|
26
26
|
}
|
|
27
27
|
/** Runs a state-changing command and throws `StepFailed` if it exits non-zero. */
|
|
@@ -93,7 +93,8 @@ export async function syncClone(system, dir) {
|
|
|
93
93
|
await mustRun(system, 'git', ['clone', '--depth', '1', '--branch', BRANCH, GIT_URL, dir]);
|
|
94
94
|
return 'cloned';
|
|
95
95
|
}
|
|
96
|
-
|
|
96
|
+
/** Throws `blocked` unless `dir` is absent or a clean clone of this repo on `main` with nothing unpushed. */
|
|
97
|
+
export async function assertCloneRemovable(system, dir) {
|
|
97
98
|
if (!(await system.exists(dir))) {
|
|
98
99
|
return;
|
|
99
100
|
}
|
|
@@ -109,6 +110,12 @@ export async function removeClone(system, dir) {
|
|
|
109
110
|
if (ahead.code !== 0 || ahead.stdout.trim() !== '0') {
|
|
110
111
|
blocked(`${dir} has commits that aren't on origin/${BRANCH}; push or drop them, or delete it yourself`);
|
|
111
112
|
}
|
|
113
|
+
}
|
|
114
|
+
export async function removeClone(system, dir) {
|
|
115
|
+
if (!(await system.exists(dir))) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
await assertCloneRemovable(system, dir);
|
|
112
119
|
await system.remove(dir);
|
|
113
120
|
}
|
|
114
121
|
/** Converts a thrown `StepFailed` into its result; anything else is an unexpected failure. */
|