dsh-plugin-toolkit 0.3.4 → 0.4.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 +22 -0
- package/README.md +15 -3
- package/lib/cli.mjs +30 -2
- package/lib/index-file.mjs +9 -2
- package/lib/pr.mjs +144 -0
- package/lib/registry.mjs +17 -2
- package/lib/release.mjs +111 -17
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
`release` can now finish the job instead of printing the next command.
|
|
6
|
+
|
|
7
|
+
- `--pr` commits the index on `market/<version>`, pushes it, and opens the pull request with `gh`.
|
|
8
|
+
- `--merge` (with `--pr`) waits for `gh pr checks` to settle and squash-merges.
|
|
9
|
+
- A repository with no CI is treated as settled rather than waited on; `gh` prints "no checks reported" on **stderr** and exits 1, which is why stderr is now piped instead of dropped.
|
|
10
|
+
- Re-running a release after a failure no longer throws: the same version with the same publish time is a no-op, and a different timestamp stops with both values in the message.
|
|
11
|
+
- A package the registry has never heard of fails immediately (`the registry has no package named …`) instead of polling for eight minutes.
|
|
12
|
+
- `release` forwards `fetch`, so a caller can answer instead of npm.
|
|
13
|
+
|
|
14
|
+
Verified end to end against a throwaway package: tag → publish → registry wait → index write → PR #3 → checks → merged.
|
|
15
|
+
|
|
16
|
+
## 0.3.5
|
|
17
|
+
|
|
18
|
+
Four bugs found by running `release` for real against a throwaway package, instead of only `--dry-run`:
|
|
19
|
+
|
|
20
|
+
- The index entry came out with `publishTimestamp: null`. The registry was asked for the abbreviated packument (`application/vnd.npm.install-v1+json`), which has no `time` field at all — so the tool could see the version and still have no publish date. It now asks for the full document, and waits for `time[version]` before writing the entry.
|
|
21
|
+
- `git rev-parse` on a tag that does not exist yet printed `fatal: ambiguous argument …` in the middle of a successful release; stderr is now dropped for that call.
|
|
22
|
+
- "the working tree has uncommitted changes" now names up to three paths.
|
|
23
|
+
- A folder without `package.json` raised a stack trace instead of returning a preflight failure.
|
|
24
|
+
|
|
3
25
|
## 0.3.4
|
|
4
26
|
|
|
5
27
|
- CI now runs on Windows as well as Linux and macOS; two tests assumed a POSIX temp path (`/tmp/…`) and were rewritten with `path.join`.
|
package/README.md
CHANGED
|
@@ -166,10 +166,22 @@ Exit code is `0` when the plugin can be installed and `1` when it cannot, so it
|
|
|
166
166
|
2. Refuse to run on a dirty tree, or if the tag already exists.
|
|
167
167
|
3. Create and push the tag, which starts the plugin's release workflow.
|
|
168
168
|
4. Poll the registry until it actually serves that version, reporting each wait.
|
|
169
|
-
5. Write the index entry with the publish time **the registry reports**, not the local clock.
|
|
170
|
-
6.
|
|
169
|
+
5. Write the index entry with the publish time **the registry reports**, not the local clock. It waits for `time[version]` specifically: the abbreviated registry document has no `time` field, and an entry with a null timestamp is rejected by the market checker.
|
|
170
|
+
6. With `--pr`: commit that entry on `market/<version>`, push it, and open the pull request. With `--merge` as well: wait for `gh pr checks` to settle, then squash-merge.
|
|
171
171
|
|
|
172
|
-
|
|
172
|
+
```sh
|
|
173
|
+
npx dsh-plugin release . --pr --merge
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
```
|
|
177
|
+
tagged plugin-v0.4.0, npm served my-plugin@0.4.0 after 82s
|
|
178
|
+
updated market/index.json on market/0.4.0
|
|
179
|
+
opened https://github.com/me/my-plugin/pull/3
|
|
180
|
+
checks settled after 1s (1 look(s))
|
|
181
|
+
merged
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Without `--pr` it prints the `git add` / `gh pr create` commands instead of running them; `--dry-run` prints the whole sequence before anything happens. A re-run after a failure is safe: the same version with the same publish time is a no-op.
|
|
173
185
|
|
|
174
186
|
## Exit codes
|
|
175
187
|
|
package/lib/cli.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import { auditVersions, readIndex } from './index-file.mjs';
|
|
|
12
12
|
export const HELP = `dsh-plugin — preflight and release DeepSeek Harness plugins
|
|
13
13
|
|
|
14
14
|
dsh-plugin doctor [dir] check the manifest, README and bundle
|
|
15
|
-
dsh-plugin release [dir] [flags] tag, wait for npm, update the market index
|
|
15
|
+
dsh-plugin release [dir] [flags] tag, wait for npm, update the market index, open the PR
|
|
16
16
|
dsh-plugin index [dir] audit every version in market/index.json
|
|
17
17
|
dsh-plugin mount <client.js> render a client bundle in Node and print what a user would see
|
|
18
18
|
dsh-plugin dev [dir] [flags] copy a rebuilt bundle into the installed plugin and confirm the reload
|
|
@@ -22,6 +22,9 @@ Flags
|
|
|
22
22
|
--index <path> index file, default market/index.json
|
|
23
23
|
--tag-prefix <prefix> default plugin-v
|
|
24
24
|
--budget <seconds> how long to wait for npm, default 480
|
|
25
|
+
--pr commit the index on a branch, push it, open the pull request
|
|
26
|
+
--merge with --pr: wait for checks, then squash-merge
|
|
27
|
+
--base <branch> base branch for the pull request, default main
|
|
25
28
|
`;
|
|
26
29
|
|
|
27
30
|
function flags(argv) {
|
|
@@ -42,6 +45,9 @@ function flags(argv) {
|
|
|
42
45
|
else if (token === '--once') parsed.once = true;
|
|
43
46
|
else if (token === '--debounce') parsed.debounce = Number(argv[++index]);
|
|
44
47
|
else if (token === '--timeout') parsed.timeout = Number(argv[++index]);
|
|
48
|
+
else if (token === '--pr') parsed.pr = true;
|
|
49
|
+
else if (token === '--merge') parsed.merge = true;
|
|
50
|
+
else if (token === '--base') parsed.base = argv[++index];
|
|
45
51
|
else if (token.startsWith('--') === true) throw new Error(`unknown flag ${token}`);
|
|
46
52
|
else parsed.positional.push(token);
|
|
47
53
|
}
|
|
@@ -152,6 +158,10 @@ export async function run(argv, io = console) {
|
|
|
152
158
|
indexFile: options.index,
|
|
153
159
|
tagPrefix: options.tagPrefix,
|
|
154
160
|
budgetMs: options.budget === undefined ? undefined : options.budget * 1000,
|
|
161
|
+
pr: options.pr === true,
|
|
162
|
+
merge: options.merge === true,
|
|
163
|
+
base: options.base,
|
|
164
|
+
onStep: (name) => io.log(` → ${name}`),
|
|
155
165
|
onWait: ({ attempts, waitedMs }) => {
|
|
156
166
|
io.log(` … ${name}@${version} not on the registry yet (${attempts} checks, ${Math.round(waitedMs / 1000)}s)`);
|
|
157
167
|
},
|
|
@@ -164,12 +174,30 @@ export async function run(argv, io = console) {
|
|
|
164
174
|
}
|
|
165
175
|
|
|
166
176
|
io.log(`tagged ${result.tag}, npm served ${name}@${version} after ${Math.round(result.waitedMs / 1000)}s`);
|
|
167
|
-
if (result.indexUpdate !== null) {
|
|
177
|
+
if (result.indexUpdate !== null && result.indexUpdate.unchanged === true) {
|
|
178
|
+
io.log(`${result.indexUpdate.file} already carried ${version}; nothing to commit`);
|
|
179
|
+
} else if (result.indexUpdate !== null && result.pullRequest === undefined) {
|
|
168
180
|
io.log(`updated ${result.indexUpdate.file} — commit it and open the PR:`);
|
|
169
181
|
io.log(` git add ${result.indexUpdate.file} && git commit -m "market: ${version}" && gh pr create --fill`);
|
|
182
|
+
} else if (result.indexUpdate !== null) {
|
|
183
|
+
io.log(`updated ${result.indexUpdate.file} on ${result.pullRequest.branch}`);
|
|
170
184
|
} else {
|
|
171
185
|
io.log('no market/index.json here, so only the tag and the publish happened');
|
|
172
186
|
}
|
|
187
|
+
|
|
188
|
+
if (result.pullRequest !== undefined) {
|
|
189
|
+
if (result.pullRequest.ok !== true) {
|
|
190
|
+
io.error(`the pull request was not opened (${result.pullRequest.stage})`);
|
|
191
|
+
return 1;
|
|
192
|
+
}
|
|
193
|
+
io.log(`opened ${result.pullRequest.url}`);
|
|
194
|
+
}
|
|
195
|
+
if (result.checks !== undefined) {
|
|
196
|
+
io.log(`checks settled after ${Math.round(result.checks.waitedMs / 1000)}s (${result.checks.attempts} look(s))`);
|
|
197
|
+
}
|
|
198
|
+
if (result.merged !== undefined && result.merged.ok === true) {
|
|
199
|
+
io.log('merged');
|
|
200
|
+
}
|
|
173
201
|
return 0;
|
|
174
202
|
}
|
|
175
203
|
|
package/lib/index-file.mjs
CHANGED
|
@@ -34,8 +34,15 @@ export function addVersion(index, name, entry) {
|
|
|
34
34
|
throw new Error(`the index has no plugin called "${name}"`);
|
|
35
35
|
}
|
|
36
36
|
const versions = Array.isArray(plugin.versions) ? plugin.versions : [];
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
const existing = versions.find((candidate) => candidate.version === entry.version);
|
|
38
|
+
if (existing !== undefined) {
|
|
39
|
+
// Re-running a release after a failure is normal, so the same version with
|
|
40
|
+
// the same publish time is a no-op; a different timestamp means the index
|
|
41
|
+
// and the registry disagree, and that is worth stopping for.
|
|
42
|
+
if (existing.publishTimestamp === entry.publishTimestamp) return index;
|
|
43
|
+
throw new Error(
|
|
44
|
+
`${name}@${entry.version} is already in the index with publishTimestamp ${existing.publishTimestamp}, not ${entry.publishTimestamp}`,
|
|
45
|
+
);
|
|
39
46
|
}
|
|
40
47
|
return {
|
|
41
48
|
...index,
|
package/lib/pr.mjs
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The last mile of a release: the index change is one line, but it still needs a
|
|
3
|
+
* branch, a pull request, a green CI, and a merge.
|
|
4
|
+
*
|
|
5
|
+
* Every side effect goes through an injected runner, so the sequence can be
|
|
6
|
+
* asserted without a network, a repository, or a `gh` login.
|
|
7
|
+
*/
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
|
+
|
|
10
|
+
// stderr is piped, not inherited: git's "fatal: ambiguous argument" for a tag
|
|
11
|
+
// that does not exist yet and gh's "no checks reported" both arrive there, and
|
|
12
|
+
// both are expected misses that must not read as failures — but they still have
|
|
13
|
+
// to be readable by the caller.
|
|
14
|
+
export function defaultGit(dir) {
|
|
15
|
+
return (args) => execFileSync('git', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function defaultGh(dir) {
|
|
19
|
+
return (args) => execFileSync('gh', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function branchName(version, prefix = 'market/') {
|
|
23
|
+
return `${prefix}${version}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Commit the index on its own branch, push it, and open the pull request.
|
|
28
|
+
* Returns `{ok, branch, url}` or `{ok:false, stage, reason}`.
|
|
29
|
+
*/
|
|
30
|
+
export async function openIndexPr(options) {
|
|
31
|
+
const {
|
|
32
|
+
dir,
|
|
33
|
+
indexFile,
|
|
34
|
+
version,
|
|
35
|
+
base = 'main',
|
|
36
|
+
branch = branchName(version),
|
|
37
|
+
git = defaultGit(dir),
|
|
38
|
+
gh = defaultGh(dir),
|
|
39
|
+
title,
|
|
40
|
+
body,
|
|
41
|
+
onStep,
|
|
42
|
+
} = options;
|
|
43
|
+
const step = (name) => {
|
|
44
|
+
if (typeof onStep === 'function') onStep(name);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
step('branch');
|
|
49
|
+
git(['checkout', '-b', branch]);
|
|
50
|
+
} catch {
|
|
51
|
+
// An existing branch means a previous run got this far; reuse it rather than
|
|
52
|
+
// failing on the one step that is safe to repeat.
|
|
53
|
+
try {
|
|
54
|
+
git(['checkout', branch]);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
return { ok: false, stage: 'branch', reason: `could not create or check out ${branch}: ${error.message}` };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
step('commit');
|
|
62
|
+
git(['add', indexFile]);
|
|
63
|
+
git(['commit', '-m', `market: ${version}`]);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
return { ok: false, stage: 'commit', reason: error.message };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
step('push');
|
|
70
|
+
git(['push', '-u', 'origin', branch]);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
return { ok: false, stage: 'push', reason: error.message };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
step('pr');
|
|
77
|
+
const url = gh([
|
|
78
|
+
'pr',
|
|
79
|
+
'create',
|
|
80
|
+
'--base',
|
|
81
|
+
base,
|
|
82
|
+
'--head',
|
|
83
|
+
branch,
|
|
84
|
+
'--title',
|
|
85
|
+
title ?? `market: ${version}`,
|
|
86
|
+
'--body',
|
|
87
|
+
body ?? `Adds ${version} to the market index, with the publish time the registry reported.`,
|
|
88
|
+
]);
|
|
89
|
+
return { ok: true, branch, url: url.split('\n').pop() ?? url };
|
|
90
|
+
} catch (error) {
|
|
91
|
+
return { ok: false, stage: 'pr', reason: error.message };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Poll `gh pr checks` until every check has settled. */
|
|
96
|
+
export async function waitForChecks(options) {
|
|
97
|
+
const {
|
|
98
|
+
url,
|
|
99
|
+
gh,
|
|
100
|
+
budgetMs = 30 * 60 * 1000,
|
|
101
|
+
intervalMs = 20 * 1000,
|
|
102
|
+
now = Date.now,
|
|
103
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
104
|
+
onWait,
|
|
105
|
+
} = options;
|
|
106
|
+
const started = now();
|
|
107
|
+
let attempts = 0;
|
|
108
|
+
for (;;) {
|
|
109
|
+
attempts += 1;
|
|
110
|
+
let output = '';
|
|
111
|
+
try {
|
|
112
|
+
output = gh(['pr', 'checks', url, '--json', 'state', '--jq', '.[].state']);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
// `gh pr checks` exits non-zero while checks are still pending, and also
|
|
115
|
+
// when the repository has no CI at all — the second case is not a wait.
|
|
116
|
+
output = String(error.stdout ?? '');
|
|
117
|
+
// `gh` writes "no checks reported on the 'x' branch" to stdout and exits 1.
|
|
118
|
+
const message = `${error.message ?? ''}${error.stdout ?? ''}${error.stderr ?? ''}`;
|
|
119
|
+
if (/no checks reported/i.test(message) === true) {
|
|
120
|
+
return { ok: true, attempts, waitedMs: now() - started, states: [], noChecks: true };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const states = output.split('\n').map((line) => line.trim()).filter((line) => line !== '');
|
|
124
|
+
const pending = states.filter((state) => ['PENDING', 'QUEUED', 'IN_PROGRESS', ''].includes(state));
|
|
125
|
+
const failed = states.filter((state) => ['FAILURE', 'CANCELLED', 'TIMED_OUT', 'ACTION_REQUIRED'].includes(state));
|
|
126
|
+
if (failed.length > 0) return { ok: false, attempts, waitedMs: now() - started, states, reason: `${failed.length} check(s) failed` };
|
|
127
|
+
if (states.length > 0 && pending.length === 0) return { ok: true, attempts, waitedMs: now() - started, states };
|
|
128
|
+
if (now() - started >= budgetMs) {
|
|
129
|
+
return { ok: false, attempts, waitedMs: now() - started, states, timedOut: true, reason: 'checks did not settle in time' };
|
|
130
|
+
}
|
|
131
|
+
if (typeof onWait === 'function') onWait({ attempts, waitedMs: now() - started, states });
|
|
132
|
+
await sleep(intervalMs);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function mergePr(options) {
|
|
137
|
+
const { url, gh, method = 'squash' } = options;
|
|
138
|
+
try {
|
|
139
|
+
gh(['pr', 'merge', url, `--${method}`]);
|
|
140
|
+
return { ok: true };
|
|
141
|
+
} catch (error) {
|
|
142
|
+
return { ok: false, reason: error.message };
|
|
143
|
+
}
|
|
144
|
+
}
|
package/lib/registry.mjs
CHANGED
|
@@ -11,8 +11,12 @@ export const REGISTRY = 'https://registry.npmjs.org';
|
|
|
11
11
|
export async function packument(name, options = {}) {
|
|
12
12
|
const base = options.registry ?? REGISTRY;
|
|
13
13
|
const request = options.fetch ?? fetch;
|
|
14
|
+
// The full document, not the abbreviated install one: the index entry needs
|
|
15
|
+
// `time[version]`, and `application/vnd.npm.install-v1+json` omits `time`
|
|
16
|
+
// entirely — which is why an abbreviated fetch can see a version and still
|
|
17
|
+
// have no publish date to write.
|
|
14
18
|
const response = await request(`${base}/${name.replace('/', '%2F')}`, {
|
|
15
|
-
headers: { accept: 'application/
|
|
19
|
+
headers: { accept: 'application/json' },
|
|
16
20
|
});
|
|
17
21
|
if (response.status === 404) return null;
|
|
18
22
|
if (response.ok === false) {
|
|
@@ -40,6 +44,12 @@ export function hasVersion(document, version) {
|
|
|
40
44
|
* `onWait` is called after each miss so the CLI can report progress.
|
|
41
45
|
*/
|
|
42
46
|
export async function waitForVersion(name, version, options = {}) {
|
|
47
|
+
// `requireTime` matters for the market index: `versions` can list a version
|
|
48
|
+
// before the registry has written its `time` entry, and an index entry with a
|
|
49
|
+
// null timestamp is rejected by the checker.
|
|
50
|
+
const ready = options.requireTime === true
|
|
51
|
+
? (document) => hasVersion(document, version) === true && publishedAt(document, version) !== null
|
|
52
|
+
: (document) => hasVersion(document, version) === true;
|
|
43
53
|
const budgetMs = options.budgetMs ?? 8 * 60 * 1000;
|
|
44
54
|
const intervalMs = options.intervalMs ?? 10 * 1000;
|
|
45
55
|
const now = options.now ?? Date.now;
|
|
@@ -50,7 +60,12 @@ export async function waitForVersion(name, version, options = {}) {
|
|
|
50
60
|
for (;;) {
|
|
51
61
|
attempts += 1;
|
|
52
62
|
const document = await packument(name, options);
|
|
53
|
-
if (
|
|
63
|
+
if (document === null && options.requirePackage !== false) {
|
|
64
|
+
// A package that does not exist will not appear by waiting: say so now
|
|
65
|
+
// instead of polling for eight minutes.
|
|
66
|
+
return { document, attempts, waitedMs: now() - started, missingPackage: true };
|
|
67
|
+
}
|
|
68
|
+
if (ready(document) === true) {
|
|
54
69
|
return { document, attempts, waitedMs: now() - started };
|
|
55
70
|
}
|
|
56
71
|
if (now() - started >= budgetMs) {
|
package/lib/release.mjs
CHANGED
|
@@ -12,26 +12,38 @@ import path from 'node:path';
|
|
|
12
12
|
import { doctor } from './doctor.mjs';
|
|
13
13
|
import { addVersion, buildEntry, readIndex, writeIndex } from './index-file.mjs';
|
|
14
14
|
import { publishedAt, waitForVersion } from './registry.mjs';
|
|
15
|
+
import { defaultGh, mergePr, openIndexPr, waitForChecks } from './pr.mjs';
|
|
15
16
|
|
|
16
17
|
function git(dir, args) {
|
|
17
|
-
|
|
18
|
+
// stderr is dropped on purpose: `rev-parse` on a tag that does not exist yet is
|
|
19
|
+
// an expected miss, and git prints "fatal: ambiguous argument" for it, which
|
|
20
|
+
// reads like a failure in the middle of a successful release.
|
|
21
|
+
return execFileSync('git', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
export function tagName(version, prefix = 'plugin-v') {
|
|
21
25
|
return `${prefix}${version}`;
|
|
22
26
|
}
|
|
23
27
|
|
|
24
|
-
export function tagExists(dir, tag) {
|
|
28
|
+
export function tagExists(dir, tag, run) {
|
|
25
29
|
try {
|
|
26
|
-
git(dir, ['rev-parse', `refs/tags/${tag}`]);
|
|
30
|
+
(run ?? ((args) => git(dir, args)))(['rev-parse', `refs/tags/${tag}`]);
|
|
27
31
|
return true;
|
|
28
32
|
} catch {
|
|
29
33
|
return false;
|
|
30
34
|
}
|
|
31
35
|
}
|
|
32
36
|
|
|
33
|
-
export function workingTreeIsClean(dir) {
|
|
34
|
-
return git(dir, ['status', '--porcelain']) === '';
|
|
37
|
+
export function workingTreeIsClean(dir, run) {
|
|
38
|
+
return (run ?? ((args) => git(dir, args)))(['status', '--porcelain']) === '';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The paths git would carry into the tag, so the message can name them. */
|
|
42
|
+
export function dirtyPaths(dir, limit = 3, run) {
|
|
43
|
+
const status = (run ?? ((args) => git(dir, args)))(['status', '--porcelain']);
|
|
44
|
+
if (status === '') return [];
|
|
45
|
+
const paths = status.split('\n').map((line) => line.slice(3).trim()).filter((line) => line !== '');
|
|
46
|
+
return paths.slice(0, limit);
|
|
35
47
|
}
|
|
36
48
|
|
|
37
49
|
/**
|
|
@@ -69,7 +81,16 @@ export async function release(options) {
|
|
|
69
81
|
onChange,
|
|
70
82
|
} = options;
|
|
71
83
|
|
|
72
|
-
const
|
|
84
|
+
const gitRun = options.git ?? ((args) => git(dir, args));
|
|
85
|
+
|
|
86
|
+
let report;
|
|
87
|
+
try {
|
|
88
|
+
report = doctor(dir);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
// A folder without package.json is a preflight failure, not a crash: the
|
|
91
|
+
// command's job is to say what is wrong, and a stack trace is not an answer.
|
|
92
|
+
return { ok: false, stage: 'preflight', failures: [{ check: 'manifest', message: error.message }] };
|
|
93
|
+
}
|
|
73
94
|
if (report.failures.length > 0) {
|
|
74
95
|
return { ok: false, stage: 'preflight', failures: report.failures };
|
|
75
96
|
}
|
|
@@ -78,21 +99,28 @@ export async function release(options) {
|
|
|
78
99
|
const version = report.manifest.version;
|
|
79
100
|
const tag = tagName(version, tagPrefix);
|
|
80
101
|
|
|
81
|
-
if (tagExists(dir, tag) === true) {
|
|
102
|
+
if (tagExists(dir, tag, gitRun) === true) {
|
|
82
103
|
return { ok: false, stage: 'tag', failures: [{ check: 'tag', message: `${tag} already exists` }] };
|
|
83
104
|
}
|
|
84
|
-
if (workingTreeIsClean(dir) === false) {
|
|
105
|
+
if (workingTreeIsClean(dir, gitRun) === false) {
|
|
106
|
+
const dirty = dirtyPaths(dir, 3, gitRun);
|
|
107
|
+
const listed = dirty.length > 0 ? ` (${dirty.join(', ')})` : '';
|
|
85
108
|
return {
|
|
86
109
|
ok: false,
|
|
87
110
|
stage: 'git',
|
|
88
|
-
failures: [{ check: 'git', message:
|
|
111
|
+
failures: [{ check: 'git', message: `the working tree has uncommitted changes${listed}` }],
|
|
89
112
|
};
|
|
90
113
|
}
|
|
91
114
|
|
|
92
|
-
|
|
93
|
-
|
|
115
|
+
gitRun(['tag', tag]);
|
|
116
|
+
gitRun(['push', 'origin', tag]);
|
|
94
117
|
|
|
95
118
|
const waited = await waitForVersion(name, version, {
|
|
119
|
+
// Wait for the publish *time* as well: `versions` can appear a moment before
|
|
120
|
+
// `time`, and an index entry with a null timestamp fails the market checker.
|
|
121
|
+
requireTime: true,
|
|
122
|
+
// Forwarded so a caller (and the tests) can answer instead of npm.
|
|
123
|
+
fetch: options.fetch,
|
|
96
124
|
registry,
|
|
97
125
|
budgetMs,
|
|
98
126
|
intervalMs,
|
|
@@ -100,6 +128,18 @@ export async function release(options) {
|
|
|
100
128
|
now,
|
|
101
129
|
onWait: options.onWait,
|
|
102
130
|
});
|
|
131
|
+
if (waited.missingPackage === true) {
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
stage: 'registry',
|
|
135
|
+
failures: [
|
|
136
|
+
{
|
|
137
|
+
check: 'registry',
|
|
138
|
+
message: `the registry has no package named ${name} (tag ${tag} is pushed; publish it, then re-run)`,
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
};
|
|
142
|
+
}
|
|
103
143
|
if (waited.timedOut === true) {
|
|
104
144
|
return {
|
|
105
145
|
ok: false,
|
|
@@ -118,14 +158,22 @@ export async function release(options) {
|
|
|
118
158
|
const indexPath = path.join(dir, indexFile);
|
|
119
159
|
let indexUpdate = null;
|
|
120
160
|
if (fs.existsSync(indexPath) === true) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
161
|
+
try {
|
|
162
|
+
const before = readIndex(indexPath);
|
|
163
|
+
const after = addVersion(before, name, entry);
|
|
164
|
+
if (after !== before) {
|
|
165
|
+
writeIndex(indexPath, after);
|
|
166
|
+
indexUpdate = { file: indexFile, version, entry };
|
|
167
|
+
if (typeof onChange === 'function') onChange(indexUpdate);
|
|
168
|
+
} else {
|
|
169
|
+
indexUpdate = { file: indexFile, version, entry, unchanged: true };
|
|
170
|
+
}
|
|
171
|
+
} catch (error) {
|
|
172
|
+
return { ok: false, stage: 'index', failures: [{ check: 'index', message: error.message }] };
|
|
173
|
+
}
|
|
126
174
|
}
|
|
127
175
|
|
|
128
|
-
|
|
176
|
+
const result = {
|
|
129
177
|
ok: true,
|
|
130
178
|
stage: 'index',
|
|
131
179
|
tag,
|
|
@@ -134,4 +182,50 @@ export async function release(options) {
|
|
|
134
182
|
waitedMs: waited.waitedMs,
|
|
135
183
|
indexUpdate,
|
|
136
184
|
};
|
|
185
|
+
|
|
186
|
+
if (options.pr !== true || indexUpdate === null) return result;
|
|
187
|
+
|
|
188
|
+
// The index change is one line, but it still needs a branch, a pull request,
|
|
189
|
+
// a green CI and a merge; doing that by hand is where "forgot to open the PR"
|
|
190
|
+
// happens.
|
|
191
|
+
const ghRunner = options.gh ?? defaultGh(dir);
|
|
192
|
+
const pull = await openIndexPr({
|
|
193
|
+
dir,
|
|
194
|
+
indexFile,
|
|
195
|
+
version,
|
|
196
|
+
base: options.base ?? 'main',
|
|
197
|
+
branch: options.branch,
|
|
198
|
+
title: options.prTitle,
|
|
199
|
+
body: options.prBody,
|
|
200
|
+
git: options.git,
|
|
201
|
+
gh: ghRunner,
|
|
202
|
+
onStep: options.onStep,
|
|
203
|
+
});
|
|
204
|
+
result.pullRequest = pull;
|
|
205
|
+
if (pull.ok !== true) {
|
|
206
|
+
return { ...result, ok: false, stage: `pr:${pull.stage}`, failures: [{ check: 'pr', message: pull.reason }] };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (options.merge !== true) return result;
|
|
210
|
+
|
|
211
|
+
const checks = await waitForChecks({
|
|
212
|
+
url: pull.url,
|
|
213
|
+
gh: ghRunner,
|
|
214
|
+
budgetMs: options.checksBudgetMs,
|
|
215
|
+
intervalMs: options.checksIntervalMs,
|
|
216
|
+
sleep: options.sleep,
|
|
217
|
+
now: options.now,
|
|
218
|
+
onWait: options.onChecks,
|
|
219
|
+
});
|
|
220
|
+
result.checks = checks;
|
|
221
|
+
if (checks.ok !== true) {
|
|
222
|
+
return { ...result, ok: false, stage: 'checks', failures: [{ check: 'checks', message: checks.reason }] };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const merged = await mergePr({ url: pull.url, gh: ghRunner, method: options.mergeMethod });
|
|
226
|
+
result.merged = merged;
|
|
227
|
+
if (merged.ok !== true) {
|
|
228
|
+
return { ...result, ok: false, stage: 'merge', failures: [{ check: 'merge', message: merged.reason }] };
|
|
229
|
+
}
|
|
230
|
+
return { ...result, stage: 'merged' };
|
|
137
231
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-toolkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Preflight, release and test DeepSeek Harness plugins: checks what the host needs, waits for npm to actually serve the version, and keeps the market index honest.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|