@skitterbyte/skitterspec-jira 0.0.0-stage → 2.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/MIGRATION.md +1266 -0
- package/README.md +44 -2
- package/assets/claude-md-section.md +85 -0
- package/assets/commands/allow-main.md +33 -0
- package/assets/commands/spec-connect.md +13 -0
- package/assets/commands/spec-live.md +14 -0
- package/assets/commands/spec-remote-review.md +37 -0
- package/assets/commands/spec-skip.md +24 -0
- package/assets/core/SETUP.md +192 -0
- package/assets/core/env.config.json.example +56 -0
- package/assets/core/env.config.md +606 -0
- package/assets/core/gating.config.json.example +4 -0
- package/assets/core/gating.config.md +81 -0
- package/assets/core/jira.config.json.example +18 -0
- package/assets/core/jira.config.md +88 -0
- package/assets/hooks/main-guard.cjs +162 -0
- package/assets/hooks/review-gate.cjs +146 -0
- package/assets/review/page.html +3384 -0
- package/assets/rules/commit-trailers.md +96 -0
- package/assets/rules/negative-checks.md +69 -0
- package/assets/rules/offered-unblocks.md +100 -0
- package/assets/rules/spec-planning.md +702 -0
- package/assets/rules/spec-reports.md +572 -0
- package/assets/skills/no-spec/SKILL.md +249 -0
- package/assets/skills/spec/SKILL.md +714 -0
- package/assets/skills/spec-bug/SKILL.md +585 -0
- package/assets/skills/spec-cancel/SKILL.md +261 -0
- package/assets/skills/spec-complete/SKILL.md +451 -0
- package/assets/skills/spec-diff/SKILL.md +870 -0
- package/assets/skills/spec-hotfix/SKILL.md +607 -0
- package/assets/skills/spec-init/SKILL.md +178 -0
- package/assets/skills/spec-jira-setup/SKILL.md +221 -0
- package/assets/skills/spec-list/SKILL.md +173 -0
- package/assets/skills/spec-next/SKILL.md +678 -0
- package/assets/skills/spec-push/SKILL.md +306 -0
- package/assets/skills/spec-review/SKILL.md +256 -0
- package/assets/skills/spec-reviewed/SKILL.md +284 -0
- package/assets/skills/spec-start/SKILL.md +496 -0
- package/assets/skills/spec-status/SKILL.md +114 -0
- package/assets/skills/spec-sync/SKILL.md +220 -0
- package/assets/skills/spec-to-main/SKILL.md +141 -0
- package/bin/skitterspec-jira.js +68 -0
- package/package.json +40 -4
- package/src/cli.js +5967 -0
- package/src/deprecate.js +138 -0
- package/src/env/bootstrap.js +188 -0
- package/src/env/building.js +143 -0
- package/src/env/classify.js +176 -0
- package/src/env/commitcmd.js +108 -0
- package/src/env/config.js +850 -0
- package/src/env/dev.js +63 -0
- package/src/env/glob.js +34 -0
- package/src/env/hooks.js +226 -0
- package/src/env/hotfix.js +413 -0
- package/src/env/hotfixland.js +302 -0
- package/src/env/integrate.js +167 -0
- package/src/env/live.js +479 -0
- package/src/env/mainguard.js +299 -0
- package/src/env/provision.js +466 -0
- package/src/env/proxy.js +225 -0
- package/src/env/prune.js +119 -0
- package/src/env/registry.js +159 -0
- package/src/env/render.js +17 -0
- package/src/env/resolve.js +716 -0
- package/src/env/review.js +2622 -0
- package/src/env/reviewers/coderabbit.js +228 -0
- package/src/env/reviewers.js +517 -0
- package/src/env/serve.js +1044 -0
- package/src/env/supervise.js +157 -0
- package/src/env/teardown.js +248 -0
- package/src/env/trust.js +87 -0
- package/src/gating.js +155 -0
- package/src/init.js +1150 -0
- package/src/lines-diff.js +114 -0
- package/src/prompts.js +129 -0
- package/src/vendor/jira/adf.js +414 -0
- package/src/vendor/jira/api.js +295 -0
- package/src/vendor/jira/cli-sync.js +1252 -0
- package/src/vendor/jira/commands.js +44 -0
- package/src/vendor/jira/config.js +286 -0
- package/src/vendor/jira/credentials.js +108 -0
- package/src/vendor/jira/dialect.js +45 -0
- package/src/vendor/jira/mcp.js +129 -0
- package/src/vendor/provider-kit/index.js +41 -0
- package/src/vendor/provider-kit/src/adapter.js +23 -0
- package/src/vendor/provider-kit/src/apply.js +207 -0
- package/src/vendor/provider-kit/src/args.js +68 -0
- package/src/vendor/provider-kit/src/assignment.js +277 -0
- package/src/vendor/provider-kit/src/config.js +251 -0
- package/src/vendor/provider-kit/src/credentials.js +404 -0
- package/src/vendor/provider-kit/src/doctor.js +35 -0
- package/src/vendor/provider-kit/src/http.js +63 -0
- package/src/vendor/provider-kit/src/identity.js +93 -0
- package/src/vendor/provider-kit/src/list-assignee.js +71 -0
- package/src/vendor/provider-kit/src/list-rows.js +35 -0
- package/src/vendor/provider-kit/src/md-compare.js +39 -0
- package/src/vendor/provider-kit/src/md-tables.js +94 -0
- package/src/vendor/provider-kit/src/prompt.js +50 -0
- package/src/vendor/provider-kit/src/released.js +219 -0
- package/src/vendor/provider-kit/src/specs.js +248 -0
- package/src/vendor/provider-kit/src/transport.js +22 -0
- package/src/vendor/sync-core/index.js +92 -0
- package/src/vendor/sync-core/src/base.js +81 -0
- package/src/vendor/sync-core/src/compare.js +338 -0
- package/src/vendor/sync-core/src/dialect.js +51 -0
- package/src/vendor/sync-core/src/fields.js +36 -0
- package/src/vendor/sync-core/src/legacy.js +100 -0
- package/src/vendor/sync-core/src/normalize.js +971 -0
- package/src/vendor/sync-core/src/push.js +85 -0
- package/src/vendor/sync-core/src/retarget.js +278 -0
- package/src/vendor/sync-core/src/task-block.js +308 -0
- package/src/vendor/sync-core/src/verify.js +67 -0
- package/src/vendor/sync-core/src/write.js +191 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure provisioning planner for `spec-env up`.
|
|
5
|
+
*
|
|
6
|
+
* Given a resolved spec and its allocated slot, `planUp` returns the commands
|
|
7
|
+
* the caller runs from the primary checkout (the spec commit, `git worktree
|
|
8
|
+
* add`) and the worktree steps `spec-env bootstrap` runs afterwards (seeds, the
|
|
9
|
+
* env file, `setup`, the stack) — but performs no side effects itself. The caller (the CLI) reads/allocates the
|
|
10
|
+
* registry and passes the slot; this stays deterministic and unit-testable with
|
|
11
|
+
* no live git/docker.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { classifyDirtyTree } = require('./classify.js')
|
|
15
|
+
const { portOffset } = require('./registry.js')
|
|
16
|
+
const { renderEnvFile } = require('./render.js')
|
|
17
|
+
const { expandTokens } = require('./resolve.js')
|
|
18
|
+
|
|
19
|
+
// Cap a path list in a message: enough to recognise, not a wall of text.
|
|
20
|
+
function listPaths(paths) {
|
|
21
|
+
const shown = paths.slice(0, 5).join(', ')
|
|
22
|
+
return paths.length > 5 ? `${shown}, and ${paths.length - 5} more` : shown
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Decide what an uncommitted tree means for a provisioning run: commit it, refuse
|
|
27
|
+
* it, or ignore it.
|
|
28
|
+
*
|
|
29
|
+
* `/spec-start` refuses a dirty tree so another spec's unfinished work is never
|
|
30
|
+
* moved without its author asking. But the commonest dirty tree of all is the
|
|
31
|
+
* spec you just wrote and are now starting, and refusing THAT costs a round trip
|
|
32
|
+
* through `/commit` on every start. So the question asked here is not "does this
|
|
33
|
+
* dirt look important?" — a judgement the gate rightly declines to make — but
|
|
34
|
+
* membership in an exactly-known set (see `classify.js`). One foreign path
|
|
35
|
+
* disqualifies the whole tree.
|
|
36
|
+
*
|
|
37
|
+
* WHAT WOULD FOOL THIS: `ctx.dirtyPaths` being absent. That is not "the tree is
|
|
38
|
+
* clean" — it is "nobody looked", which happens on every legacy caller and
|
|
39
|
+
* whenever git itself could not be read. Either way it is never permission to
|
|
40
|
+
* commit: with no classification there is no owned set, so no commit is planned.
|
|
41
|
+
*
|
|
42
|
+
* Whether it also REFUSES depends on `carriesChanges`, and the asymmetry is the
|
|
43
|
+
* two modes' actual risk, not an inconsistency:
|
|
44
|
+
*
|
|
45
|
+
* - checkout mode (`carriesChanges`) — `git switch -c` silently carries
|
|
46
|
+
* uncommitted work onto the new branch. Unable to see the tree means unable
|
|
47
|
+
* to rule that out, so it refuses, exactly as it did before this gate existed.
|
|
48
|
+
* - worktree mode — `git worktree add` carries nothing, and forks from a commit
|
|
49
|
+
* regardless. Refusing here would fire on a healthy repo whose git we merely
|
|
50
|
+
* could not read, which is a cost paid by someone who did nothing wrong. So
|
|
51
|
+
* it plans no commit and provisions as before.
|
|
52
|
+
*
|
|
53
|
+
* `specOnFork` is the other half, and a POSITIVE signal rather than an absence:
|
|
54
|
+
* the worktree forks from a specific commit, so a spec absent from it produces a
|
|
55
|
+
* branch missing the very spec it is for. A clean tree cannot detect that — the
|
|
56
|
+
* spec may be committed, just somewhere else. `null` means the caller could not
|
|
57
|
+
* tell (an unreadable git, or a hotfix, which forks from a tag predating its own
|
|
58
|
+
* spec), and routes to carrying on, never to refusing.
|
|
59
|
+
*
|
|
60
|
+
* FOREIGN DIRT IS THE SAME ASYMMETRY, for the same reason. Another spec's
|
|
61
|
+
* uncommitted work refuses in checkout mode, where `git switch -c` would carry
|
|
62
|
+
* it onto the new branch, and does not in worktree mode, where nothing carries
|
|
63
|
+
* anywhere. Refusing there fired on the commonest tree this workflow produces —
|
|
64
|
+
* author spec B while spec A is still uncommitted, then start B — and prevented
|
|
65
|
+
* nothing, since the only write to the primary checkout is a pathspec-limited
|
|
66
|
+
* commit of this spec's own paths. It is reported instead, on `foreign`.
|
|
67
|
+
*
|
|
68
|
+
* ctx: { dirtyPaths?, clean?, specOnFork?, specFoundOn?, forkRef?, specUntracked? }
|
|
69
|
+
* @returns {{blocked: boolean, reason: string|null, commands: string[],
|
|
70
|
+
* owned: string[], foreign: string[]}}
|
|
71
|
+
*/
|
|
72
|
+
function planSpecCommit(spec, ctx, config, { carriesChanges = false } = {}) {
|
|
73
|
+
const ok = { blocked: false, reason: null, commands: [], owned: [], verb: null, foreign: [] }
|
|
74
|
+
const c = ctx || {}
|
|
75
|
+
|
|
76
|
+
if (!Array.isArray(c.dirtyPaths)) {
|
|
77
|
+
// Nobody looked: never commit, and refuse only where switching could carry
|
|
78
|
+
// work we cannot see (see the asymmetry above).
|
|
79
|
+
if (carriesChanges && c.clean === false) {
|
|
80
|
+
return {
|
|
81
|
+
blocked: true,
|
|
82
|
+
reason:
|
|
83
|
+
'the primary checkout has uncommitted changes — commit or stash them first' +
|
|
84
|
+
(carriesChanges ? ' (switching would carry them onto the new branch)' : ''),
|
|
85
|
+
commands: [],
|
|
86
|
+
owned: [],
|
|
87
|
+
verb: null,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return ok
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const { owned, foreign } = classifyDirtyTree(spec, c.dirtyPaths, config)
|
|
94
|
+
|
|
95
|
+
// FOREIGN DIRT REFUSES ONLY WHERE IT CAN REACH THE BRANCH, which is checkout
|
|
96
|
+
// mode and nowhere else. `git switch -c` carries the working tree onto the new
|
|
97
|
+
// branch, silently, so there it is someone else's work being moved without
|
|
98
|
+
// them asking. `git worktree add` carries nothing and forks from a commit, so
|
|
99
|
+
// in worktree mode the same tree is simply none of this run's business.
|
|
100
|
+
//
|
|
101
|
+
// WHAT WOULD MAKE THIS UNSAFE AGAIN: an unbounded commit. The only thing this
|
|
102
|
+
// run does to the primary checkout is the spec commit below, and it is safe to
|
|
103
|
+
// leave a colleague's files sitting beside it only because that commit is
|
|
104
|
+
// pathspec-limited on BOTH halves (`git add --`, `git commit … --`) and so
|
|
105
|
+
// cannot reach a path it does not own — not even one another session has
|
|
106
|
+
// already staged into the shared index. Drop the `--` and this stops being a
|
|
107
|
+
// false guard and starts being a missing one.
|
|
108
|
+
if (foreign.length && carriesChanges) {
|
|
109
|
+
return {
|
|
110
|
+
blocked: true,
|
|
111
|
+
reason:
|
|
112
|
+
`the primary checkout has uncommitted changes that are not ${spec.folder}'s — ` +
|
|
113
|
+
'commit or stash them first (switching would carry them onto the new branch)' +
|
|
114
|
+
`: ${listPaths(foreign)}`,
|
|
115
|
+
commands: [],
|
|
116
|
+
owned: [],
|
|
117
|
+
verb: null,
|
|
118
|
+
foreign,
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (owned.length) {
|
|
123
|
+
// `add` or `update`? Asked of git (`ctx.specUntracked`, from `git ls-files`)
|
|
124
|
+
// rather than inferred from the shape of the status output. The inference —
|
|
125
|
+
// "a bare directory entry means the folder is wholly untracked" — was wrong in
|
|
126
|
+
// the ordinary case of the FIRST spec in a bucket, where git reports the
|
|
127
|
+
// bucket as the untracked directory instead. The old inference stays as a
|
|
128
|
+
// fallback for callers that pass no fact; unknown yields `update`, which is
|
|
129
|
+
// never a false claim.
|
|
130
|
+
const isNew =
|
|
131
|
+
typeof c.specUntracked === 'boolean'
|
|
132
|
+
? c.specUntracked
|
|
133
|
+
: owned.includes(`specs/${spec.bucket}/${spec.folder}`)
|
|
134
|
+
const verb = isNew ? 'add' : 'update'
|
|
135
|
+
// BOTH HALVES ARE PATHSPEC-LIMITED, and they answer different failures.
|
|
136
|
+
// `add` is what makes a path git has never seen committable at all — every
|
|
137
|
+
// brand-new spec folder is untracked. The `--` on the COMMIT is what bounds
|
|
138
|
+
// what lands: a checkout has one `.git/index` and every session standing in
|
|
139
|
+
// it shares that index, so a bare `git commit` takes whatever another
|
|
140
|
+
// session has already staged, however exactly this one named its own paths.
|
|
141
|
+
// Dropping the `--` would leave the naming above as decoration.
|
|
142
|
+
const paths = owned.map((p) => `"${p}"`).join(' ')
|
|
143
|
+
return {
|
|
144
|
+
blocked: false,
|
|
145
|
+
reason: null,
|
|
146
|
+
owned,
|
|
147
|
+
verb,
|
|
148
|
+
foreign,
|
|
149
|
+
commands: [
|
|
150
|
+
`git add -- ${paths}`,
|
|
151
|
+
`git commit -m "chore(spec): ${verb} ${spec.folder}" -- ${paths}`,
|
|
152
|
+
],
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Tree is clean. The spec must already be in the base branch's tree, or the
|
|
157
|
+
// worktree forks without it.
|
|
158
|
+
// Nothing of this spec's is uncommitted, but somebody else's may be — say so
|
|
159
|
+
// rather than returning the bare `ok`, or the caller has no way to report what
|
|
160
|
+
// it left alone.
|
|
161
|
+
if (foreign.length && c.specOnFork !== false) return { ...ok, foreign }
|
|
162
|
+
|
|
163
|
+
if (c.specOnFork === false) {
|
|
164
|
+
return {
|
|
165
|
+
blocked: true,
|
|
166
|
+
reason:
|
|
167
|
+
`${spec.folder} is not committed in ${c.forkRef || 'the fork point'}` +
|
|
168
|
+
(c.specFoundOn ? ` — it is on ${c.specFoundOn}` : '') +
|
|
169
|
+
' — the worktree would fork without the spec it is for',
|
|
170
|
+
commands: [],
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return ok
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The `-f <composeFile>` argument for a `docker compose` invocation.
|
|
178
|
+
*
|
|
179
|
+
* ONLY when the configured file is known to be present, and that condition is
|
|
180
|
+
* the whole point. `docker.composeFile` was read, normalised and validated by
|
|
181
|
+
* the config loader and then never passed to docker at all, so a project naming
|
|
182
|
+
* a non-default file was silently relying on docker's own discovery instead.
|
|
183
|
+
* Passing it unconditionally would fix that project and BREAK the mirror image
|
|
184
|
+
* of it — one whose compose file is `compose.yaml` (a name docker finds by
|
|
185
|
+
* itself) while this key sits at its untouched default of `docker-compose.yml`.
|
|
186
|
+
*
|
|
187
|
+
* So the flag is added on the positive signal and omitted otherwise, which
|
|
188
|
+
* leaves docker's discovery exactly as it is for everyone it was already
|
|
189
|
+
* working for.
|
|
190
|
+
*/
|
|
191
|
+
function composeFileArg(config, present) {
|
|
192
|
+
const file = config && config.docker && config.docker.composeFile
|
|
193
|
+
return present === true && file ? ` -f ${file}` : ''
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Does this spec want a Docker stack?
|
|
198
|
+
*
|
|
199
|
+
* ONE function, called by `planUp` and by the `up` command in `cli.js`. They
|
|
200
|
+
* used to each carry their own copy and the copies disagreed: the CLI read
|
|
201
|
+
* `spec.stack === 'docker'` with no fallback, so a header-less spec allocated no
|
|
202
|
+
* registry slot, while the planner fell back to the master switch and emitted
|
|
203
|
+
* `docker compose up -d` anyway — a stack brought up on a port offset derived
|
|
204
|
+
* from a slot that was never allocated.
|
|
205
|
+
*
|
|
206
|
+
* A SPEC WITH NO `Stack:` HEADER NEEDS THE COMPOSE FILE TO BE PRESENT. The
|
|
207
|
+
* master switch alone is not evidence that there is a stack to bring up: it is
|
|
208
|
+
* a project-wide default, and `/spec` has written the header on every spec for
|
|
209
|
+
* some time, so the specs reaching this fallback are legacy or hand-written. The
|
|
210
|
+
* compose file is the positive signal that the escalation would land on
|
|
211
|
+
* something real (`.claude/rules/negative-checks.md` rule 1).
|
|
212
|
+
*
|
|
213
|
+
* WHAT WOULD FOOL THIS, deliberately unhandled: a project whose compose file is
|
|
214
|
+
* generated during `setup` rather than committed. It does not exist when the
|
|
215
|
+
* plan is built, so a header-less spec there resolves to `worktree`. The answer
|
|
216
|
+
* is an explicit `> **Stack:** worktree + docker` header, which is checked first
|
|
217
|
+
* and never consults this at all — so being wrong costs a header, not a stack.
|
|
218
|
+
*
|
|
219
|
+
* `composeFilePresent` undefined means the caller did not look, which is
|
|
220
|
+
* cannot-tell rather than false. It routes to no escalation, the harmless
|
|
221
|
+
* branch: a missing stack surfaces as a header to add, where a spurious one
|
|
222
|
+
* surfaces as `docker compose` failing mid-provision (rule 4).
|
|
223
|
+
*/
|
|
224
|
+
function resolveStack(spec, config, { docs = false, composeFilePresent } = {}) {
|
|
225
|
+
const stack =
|
|
226
|
+
spec.stack || (config.docker.enabled && composeFilePresent === true ? 'docker' : 'worktree')
|
|
227
|
+
return { stack, wantsDocker: stack === 'docker' && config.docker.enabled && !docs }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The steps that bring a fresh worktree to ready, as data — the list
|
|
232
|
+
* `spec-env bootstrap` runs. In the order each depends on the last: seeds (the
|
|
233
|
+
* env file may be one), the env file, `setup` (which may derive values into
|
|
234
|
+
* it), and only then the stack.
|
|
235
|
+
*
|
|
236
|
+
* DATA AND NOT SHELL, and that is the reason the verb exists. These steps were
|
|
237
|
+
* printed as POSIX-sh one-liners for the agent to copy, and the copy was never
|
|
238
|
+
* faithful: merged into a loop, its echo text reworded, two steps glued into one
|
|
239
|
+
* call. A consumer's secrets hook — which refuses any Bash call naming a `.env`
|
|
240
|
+
* file and makes an exception only for the exact printed shape — then refused
|
|
241
|
+
* the rewrite, and bootstrap stopped with the user running it by hand. A step
|
|
242
|
+
* the engine runs itself has no text for anything to rewrite or refuse.
|
|
243
|
+
*
|
|
244
|
+
* `setup` entries stay shell strings: they are the project's own commands,
|
|
245
|
+
* written by the project. The docker step is an argv, run without a shell.
|
|
246
|
+
*
|
|
247
|
+
* Pure: `offset` is the spec's port offset (null without a stack), and every
|
|
248
|
+
* filesystem fact arrives in `opts`.
|
|
249
|
+
*/
|
|
250
|
+
function worktreeSteps(spec, offset, config, opts = {}) {
|
|
251
|
+
const { docs = false, wantsDocker = false, composeFilePresent: compose } = opts
|
|
252
|
+
const seed = config.seedFiles || { mode: 'symlink', files: [] }
|
|
253
|
+
const mode = seed.mode === 'copy' ? 'copy' : 'symlink'
|
|
254
|
+
const tokens = {
|
|
255
|
+
worktreePath: spec.worktreePath,
|
|
256
|
+
slug: spec.slug,
|
|
257
|
+
branch: spec.branch,
|
|
258
|
+
projectName: spec.projectName,
|
|
259
|
+
portOffset: offset === null || offset === undefined ? '' : String(offset),
|
|
260
|
+
}
|
|
261
|
+
const envFile = (config.docker && config.docker.envFile) || '.env'
|
|
262
|
+
const steps = (seed.files || []).map((file) => ({ kind: 'seed', file, mode }))
|
|
263
|
+
if (wantsDocker) {
|
|
264
|
+
steps.push({ kind: 'envFile', file: envFile, projectName: spec.projectName, offset })
|
|
265
|
+
}
|
|
266
|
+
// Documents mode drops `setup` and nothing else: seeding is cheap and a spec
|
|
267
|
+
// author may well want the gitignored files present, while `setup` is the
|
|
268
|
+
// expensive half that exists to make the tree run. `wantsDocker` is already
|
|
269
|
+
// false under `--docs` (`resolveStack`), so the stack drops with it.
|
|
270
|
+
if (!docs) {
|
|
271
|
+
for (const cmd of config.setup || []) {
|
|
272
|
+
steps.push({ kind: 'setup', command: expandTokens(cmd, tokens) })
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (wantsDocker) {
|
|
276
|
+
const file = composeFileArg(config, compose).trim().split(/\s+/).filter(Boolean)
|
|
277
|
+
steps.push({
|
|
278
|
+
kind: 'docker',
|
|
279
|
+
argv: [
|
|
280
|
+
'docker',
|
|
281
|
+
'compose',
|
|
282
|
+
...file,
|
|
283
|
+
'--env-file',
|
|
284
|
+
envFile,
|
|
285
|
+
'--project-name',
|
|
286
|
+
spec.projectName,
|
|
287
|
+
'up',
|
|
288
|
+
'-d',
|
|
289
|
+
],
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
return steps
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Plan a provisioning run.
|
|
297
|
+
*
|
|
298
|
+
* @param {object} spec resolved spec (from resolveSpec): { slug, type, branch,
|
|
299
|
+
* worktreePath, projectName, ... }
|
|
300
|
+
* @param {object} alloc { slot, attached } — attached:true when the slot already
|
|
301
|
+
* existed in the registry (re-run → attach, don't clobber).
|
|
302
|
+
* @param {object} config normalised env config.
|
|
303
|
+
* @param {object} opts { docs } — documents mode (see below). Optional.
|
|
304
|
+
* @returns {object} { worktreePath, branch, projectName, slot, portOffset,
|
|
305
|
+
* envContents, commands, steps, docs, attached }
|
|
306
|
+
*
|
|
307
|
+
* `commands` run from the primary checkout (the spec commit, the fork); `steps`
|
|
308
|
+
* are what `spec-env bootstrap` runs in the worktree afterwards.
|
|
309
|
+
*/
|
|
310
|
+
function planUp(spec, alloc, config, ctx, opts = {}) {
|
|
311
|
+
const { slot, attached } = alloc
|
|
312
|
+
|
|
313
|
+
// DOCUMENTS MODE — a worktree to write a spec in, not to run code in.
|
|
314
|
+
//
|
|
315
|
+
// `/spec` provisions before it writes the spec folder, so that authoring never
|
|
316
|
+
// touches the base branch. What it needs is a checkout on a branch; what it
|
|
317
|
+
// does not need is `pnpm install --frozen-lockfile` and a database, to write
|
|
318
|
+
// markdown. So this mode keeps the fork and drops everything whose only
|
|
319
|
+
// purpose is making the tree runnable.
|
|
320
|
+
//
|
|
321
|
+
// It is not a lesser provision that has to be undone: `/spec-start` re-runs
|
|
322
|
+
// `up` over the same worktree WITHOUT this flag, and the setup commands run
|
|
323
|
+
// then — at the first moment the tree is used for code.
|
|
324
|
+
const docs = opts.docs === true
|
|
325
|
+
|
|
326
|
+
// Per-spec escalation, decided in ONE place (`resolveStack`) so the planner and
|
|
327
|
+
// the CLI cannot answer it differently — which they did, for exactly the
|
|
328
|
+
// header-less spec this gate is about.
|
|
329
|
+
const { wantsDocker } = resolveStack(spec, config, {
|
|
330
|
+
docs,
|
|
331
|
+
composeFilePresent: ctx ? ctx.composeFilePresent : undefined,
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
// Slot, port block and `.env` are Docker-only. A worktree-only spec takes none
|
|
335
|
+
// of them: no registry slot, no PORT_OFFSET, no `.env`.
|
|
336
|
+
const offset = wantsDocker ? portOffset(slot, config) : null
|
|
337
|
+
const envContents = wantsDocker
|
|
338
|
+
? renderEnvFile({ projectName: spec.projectName, portOffset: offset })
|
|
339
|
+
: null
|
|
340
|
+
|
|
341
|
+
// Everything after the fork runs in the worktree, and `spec-env bootstrap`
|
|
342
|
+
// runs it — the steps are data, not shell for a caller to copy.
|
|
343
|
+
const steps = worktreeSteps(spec, offset, config, {
|
|
344
|
+
docs,
|
|
345
|
+
wantsDocker,
|
|
346
|
+
composeFilePresent: ctx ? ctx.composeFilePresent : undefined,
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
const commands = []
|
|
350
|
+
// Fresh branch → -b; attach an existing branch/slot → plain form (never clobber).
|
|
351
|
+
// A hotfix forks its fresh branch from a release tag (`spec.baseRef`, e.g.
|
|
352
|
+
// `v33.16.4`) instead of base HEAD — so the fix is built on the exact commit
|
|
353
|
+
// line prod runs. Attaching an existing branch ignores baseRef (already forked).
|
|
354
|
+
const forkPoint = !attached && spec.baseRef ? ` ${spec.baseRef}` : ''
|
|
355
|
+
commands.push(
|
|
356
|
+
attached
|
|
357
|
+
? `git worktree add ${spec.worktreePath} ${spec.branch}`
|
|
358
|
+
: `git worktree add ${spec.worktreePath} -b ${spec.branch}${forkPoint}`,
|
|
359
|
+
)
|
|
360
|
+
// The tree gate. A worktree forks from the base branch's tree, so an
|
|
361
|
+
// uncommitted spec would produce a branch missing the spec it is for — this is
|
|
362
|
+
// where that gets committed, or refused. Worktree mode had NO clean gate before
|
|
363
|
+
// this: `git worktree add` does not carry uncommitted changes anywhere, so the
|
|
364
|
+
// failure surfaced three steps later as a live-overlay refusal naming the wrong
|
|
365
|
+
// stage. An absent `ctx` means no caller looked, and changes nothing.
|
|
366
|
+
const gate = planSpecCommit(spec, ctx, config)
|
|
367
|
+
|
|
368
|
+
return {
|
|
369
|
+
blocked: gate.blocked,
|
|
370
|
+
reason: gate.reason,
|
|
371
|
+
specCommit: gate.owned && gate.owned.length ? { paths: gate.owned, verb: gate.verb } : null,
|
|
372
|
+
// What this run deliberately did not touch. Carried out rather than dropped
|
|
373
|
+
// because "provisioned, and left four of your files alone" is a different
|
|
374
|
+
// report from "provisioned", and the caller cannot reconstruct it.
|
|
375
|
+
untouched: gate.foreign || [],
|
|
376
|
+
worktreePath: spec.worktreePath,
|
|
377
|
+
branch: spec.branch,
|
|
378
|
+
projectName: spec.projectName,
|
|
379
|
+
slot: wantsDocker ? slot : null,
|
|
380
|
+
portOffset: offset,
|
|
381
|
+
envContents,
|
|
382
|
+
commands: gate.blocked ? [] : [...gate.commands, ...commands],
|
|
383
|
+
// The worktree steps, in the order `spec-env bootstrap` runs them. Documents
|
|
384
|
+
// mode drops `setup` and the stack and keeps the seeds (`worktreeSteps`).
|
|
385
|
+
steps: gate.blocked ? [] : steps,
|
|
386
|
+
// Reported so a caller reading `--json` can tell a documents worktree from a
|
|
387
|
+
// full one POSITIVELY, rather than inferring it from what is missing. A step
|
|
388
|
+
// list with no `setup` also describes a project that configured none, and
|
|
389
|
+
// those two must not read alike.
|
|
390
|
+
docs,
|
|
391
|
+
attached,
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Plan `spec-env up` in CHECKOUT mode — the branch is built in the primary
|
|
397
|
+
* checkout and there is no worktree at all.
|
|
398
|
+
*
|
|
399
|
+
* Pure: every git fact it needs arrives in `ctx`, and it writes nothing.
|
|
400
|
+
*
|
|
401
|
+
* ctx: { current, base, onBase, clean, branchExists }
|
|
402
|
+
*
|
|
403
|
+
* What it refuses, and why each is a refusal rather than a warning:
|
|
404
|
+
* - a dirty tree, because `git switch -c` CARRIES uncommitted changes onto the
|
|
405
|
+
* new branch. That is silent and it is the operator's work, so it is theirs
|
|
406
|
+
* to place, not ours.
|
|
407
|
+
* - standing on another branch, because switching away from it is a decision
|
|
408
|
+
* about someone else's unfinished spec. Being on THIS spec's branch is not a
|
|
409
|
+
* refusal — it is the re-run, and the answer is "already attached".
|
|
410
|
+
*
|
|
411
|
+
* There is no bootstrap: the primary checkout already has its dependencies.
|
|
412
|
+
*
|
|
413
|
+
* AND THAT IS WHY `--docs` IS INERT HERE, rather than unimplemented. Documents
|
|
414
|
+
* mode exists to skip making a fresh tree runnable; this mode makes no fresh
|
|
415
|
+
* tree, so there is nothing for it to skip. The flag is accepted and changes
|
|
416
|
+
* nothing, because a caller passing it is asking for a cheap checkout and gets
|
|
417
|
+
* one — refusing would make `/spec` mode-aware for no behavioural difference.
|
|
418
|
+
*/
|
|
419
|
+
function planCheckoutUp(spec, ctx, config) {
|
|
420
|
+
const base = ctx.base || (config && config.baseBranch) || 'main'
|
|
421
|
+
const result = {
|
|
422
|
+
mode: 'checkout',
|
|
423
|
+
blocked: false,
|
|
424
|
+
reason: null,
|
|
425
|
+
attached: false,
|
|
426
|
+
branch: spec.branch,
|
|
427
|
+
checkoutPath: ctx.checkoutPath || null,
|
|
428
|
+
commands: [],
|
|
429
|
+
}
|
|
430
|
+
const block = (reason) => ({ ...result, blocked: true, reason })
|
|
431
|
+
|
|
432
|
+
// Order matters: report "already attached" before anything else, so a re-run
|
|
433
|
+
// on the spec's own branch is never refused for a dirty tree it legitimately
|
|
434
|
+
// has — you are mid-phase, with the phase's own edits in progress.
|
|
435
|
+
if (ctx.current && ctx.current === spec.branch) {
|
|
436
|
+
return { ...result, attached: true }
|
|
437
|
+
}
|
|
438
|
+
// Same gate as worktree mode, but `git switch -c` genuinely CARRIES uncommitted
|
|
439
|
+
// work onto the new branch, so the refusal keeps saying so.
|
|
440
|
+
const gate = planSpecCommit(spec, ctx, config, { carriesChanges: true })
|
|
441
|
+
if (gate.blocked) return block(gate.reason)
|
|
442
|
+
if (!ctx.onBase) {
|
|
443
|
+
return block(
|
|
444
|
+
`the primary checkout is on ${ctx.current || '(detached)'}, not ${base} — ` +
|
|
445
|
+
'finish or park that branch first; checkout mode holds one spec at a time',
|
|
446
|
+
)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (gate.owned && gate.owned.length) {
|
|
450
|
+
result.specCommit = { paths: gate.owned, verb: gate.verb }
|
|
451
|
+
}
|
|
452
|
+
result.commands.push(...gate.commands)
|
|
453
|
+
result.commands.push(
|
|
454
|
+
ctx.branchExists ? `git switch ${spec.branch}` : `git switch -c ${spec.branch}`,
|
|
455
|
+
)
|
|
456
|
+
return result
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
module.exports = {
|
|
460
|
+
planUp,
|
|
461
|
+
planCheckoutUp,
|
|
462
|
+
planSpecCommit,
|
|
463
|
+
worktreeSteps,
|
|
464
|
+
resolveStack,
|
|
465
|
+
composeFileArg,
|
|
466
|
+
}
|
package/src/env/proxy.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Front-door reverse proxy for `spec-env connect` — a small, dependency-free
|
|
5
|
+
* Node proxy that exposes the ONE connected spec's warm dev servers on the
|
|
6
|
+
* canonical ports (exclusive model: no cookie, one target at a time).
|
|
7
|
+
*
|
|
8
|
+
* `renderRoutes` is pure (procs → routes). `startProxy` builds one `http` server
|
|
9
|
+
* per route, forwarding HTTP and `upgrade` (WebSocket/HMR) to the spec's port.
|
|
10
|
+
* Run directly (`node proxy.js <routesFile>`) it becomes the long-lived,
|
|
11
|
+
* detached proxy process the CLI supervises with the Phase 1 seam.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const http = require('node:http')
|
|
15
|
+
const net = require('node:net')
|
|
16
|
+
const fs = require('node:fs')
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The connected spec's `dev` processes that declare a `frontPort` → proxy routes.
|
|
20
|
+
* @param {Array} procs from `planDev(...).procs`
|
|
21
|
+
* @returns {Array<{name, frontPort, targetPort}>}
|
|
22
|
+
*/
|
|
23
|
+
function renderRoutes(procs) {
|
|
24
|
+
return (procs || [])
|
|
25
|
+
.filter((p) => typeof p.frontPort === 'number')
|
|
26
|
+
.map((p) => ({ name: p.name, frontPort: p.frontPort, targetPort: p.port }))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Build (but don't listen on) one proxy server: frontPort → targetPort. Forwards
|
|
30
|
+
// regular requests and transparently pipes `upgrade` (WebSocket) connections.
|
|
31
|
+
function createRouteServer(route, host = '127.0.0.1') {
|
|
32
|
+
const { targetPort } = route
|
|
33
|
+
|
|
34
|
+
const server = http.createServer((req, res) => {
|
|
35
|
+
const upstream = http.request(
|
|
36
|
+
{ host, port: targetPort, method: req.method, path: req.url, headers: req.headers },
|
|
37
|
+
(ur) => {
|
|
38
|
+
res.writeHead(ur.statusCode || 502, ur.headers)
|
|
39
|
+
ur.pipe(res)
|
|
40
|
+
},
|
|
41
|
+
)
|
|
42
|
+
upstream.on('error', () => {
|
|
43
|
+
if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain' })
|
|
44
|
+
res.end(`spec-connect: upstream 127.0.0.1:${targetPort} not reachable\n`)
|
|
45
|
+
})
|
|
46
|
+
req.pipe(upstream)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// WebSocket / HTTP upgrade passthrough — reconstruct the request line + headers
|
|
50
|
+
// onto a raw TCP connection to the upstream, then pipe both ways.
|
|
51
|
+
server.on('upgrade', (req, socket, head) => {
|
|
52
|
+
const upstream = net.connect(targetPort, host, () => {
|
|
53
|
+
let raw = `${req.method} ${req.url} HTTP/1.1\r\n`
|
|
54
|
+
for (let i = 0; i < req.rawHeaders.length; i += 2) {
|
|
55
|
+
raw += `${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}\r\n`
|
|
56
|
+
}
|
|
57
|
+
raw += '\r\n'
|
|
58
|
+
upstream.write(raw)
|
|
59
|
+
if (head && head.length) upstream.write(head)
|
|
60
|
+
socket.pipe(upstream)
|
|
61
|
+
upstream.pipe(socket)
|
|
62
|
+
})
|
|
63
|
+
upstream.on('error', () => socket.destroy())
|
|
64
|
+
socket.on('error', () => upstream.destroy())
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
return server
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Start every route's server listening. Returns `{ servers, close() }` where
|
|
72
|
+
* `close()` resolves once all servers are shut. Rejects (via the returned
|
|
73
|
+
* promise on the server 'error') if a port can't be bound.
|
|
74
|
+
*/
|
|
75
|
+
function startProxy(routes, { host = '127.0.0.1' } = {}) {
|
|
76
|
+
const servers = routes.map((route) => {
|
|
77
|
+
const server = createRouteServer(route, host)
|
|
78
|
+
server.listen(route.frontPort, host)
|
|
79
|
+
return server
|
|
80
|
+
})
|
|
81
|
+
return {
|
|
82
|
+
servers,
|
|
83
|
+
close() {
|
|
84
|
+
return Promise.all(
|
|
85
|
+
servers.map((s) => new Promise((resolve) => s.close(() => resolve()))),
|
|
86
|
+
)
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Which of `ports` are already bound (so `connect` can tell the operator to stop
|
|
93
|
+
* main first). Resolves to the list of busy ports. `host` scopes the check.
|
|
94
|
+
*/
|
|
95
|
+
function portsInUse(ports, host = '127.0.0.1') {
|
|
96
|
+
return Promise.all(
|
|
97
|
+
ports.map(
|
|
98
|
+
(port) =>
|
|
99
|
+
new Promise((resolve) => {
|
|
100
|
+
const tester = net
|
|
101
|
+
.createServer()
|
|
102
|
+
.once('error', () => resolve(port)) // EADDRINUSE → busy
|
|
103
|
+
.once('listening', () => tester.close(() => resolve(null)))
|
|
104
|
+
.listen(port, host)
|
|
105
|
+
}),
|
|
106
|
+
),
|
|
107
|
+
).then((results) => results.filter((p) => p !== null))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Does `port` accept a TCP connection right now?
|
|
111
|
+
function checkListening(port, host) {
|
|
112
|
+
return new Promise((resolve) => {
|
|
113
|
+
const sock = net.connect(port, host)
|
|
114
|
+
sock.once('connect', () => {
|
|
115
|
+
sock.destroy()
|
|
116
|
+
resolve(true)
|
|
117
|
+
})
|
|
118
|
+
sock.once('error', () => {
|
|
119
|
+
sock.destroy()
|
|
120
|
+
resolve(false)
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Poll until every port in `ports` accepts a connection (the detached proxy has
|
|
127
|
+
* finished binding), or `timeoutMs` elapses. Resolves true when all are up.
|
|
128
|
+
* `now`/`wait` are injectable for tests.
|
|
129
|
+
*/
|
|
130
|
+
async function waitListening(
|
|
131
|
+
ports,
|
|
132
|
+
{ host = '127.0.0.1', timeoutMs = 3000, intervalMs = 50, now = () => Date.now(), wait } = {},
|
|
133
|
+
) {
|
|
134
|
+
const sleep = wait || ((ms) => new Promise((r) => setTimeout(r, ms)))
|
|
135
|
+
const deadline = now() + timeoutMs
|
|
136
|
+
while (now() < deadline) {
|
|
137
|
+
const results = await Promise.all(ports.map((p) => checkListening(p, host)))
|
|
138
|
+
if (results.every(Boolean)) return true
|
|
139
|
+
await sleep(intervalMs)
|
|
140
|
+
}
|
|
141
|
+
return false
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Ask whether `port` is free on EVERY host it will need, one host at a time.
|
|
147
|
+
*
|
|
148
|
+
* SEQUENTIAL IS THE WHOLE POINT, and it is not a style choice. The probe works
|
|
149
|
+
* by binding, so two probes of the same port running concurrently contend with
|
|
150
|
+
* each other — and on Linux a wildcard bind and a loopback bind of one port are
|
|
151
|
+
* mutually exclusive, so the pair reports a completely free port as busy. The
|
|
152
|
+
* review server then refused to start on every Linux machine, which is to say
|
|
153
|
+
* on CI, while every macOS run stayed green because BSD lets the two coexist.
|
|
154
|
+
*
|
|
155
|
+
* Probing one host at a time asks the question that was always intended — is
|
|
156
|
+
* anything ELSE holding this port — instead of racing the caller against
|
|
157
|
+
* itself. Verified on node:24-alpine: parallel reports busy on a free port,
|
|
158
|
+
* sequential does not; both still refuse a real squatter on either address.
|
|
159
|
+
*
|
|
160
|
+
* `probe` is injectable so the concurrency can be asserted without sockets.
|
|
161
|
+
*/
|
|
162
|
+
async function portsInUseOn(port, hosts, probe = portsInUse) {
|
|
163
|
+
const busy = []
|
|
164
|
+
for (const host of [...new Set(hosts)]) {
|
|
165
|
+
busy.push(...(await probe([port], host)))
|
|
166
|
+
}
|
|
167
|
+
return [...new Set(busy)]
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Which of `ports` accept a connection on `host` — "is a dev server running?".
|
|
172
|
+
*
|
|
173
|
+
* CONNECTS, never binds. A bind probe on 127.0.0.1 SUCCEEDS on macOS beside a
|
|
174
|
+
* server listening on every address (Vite `host: true`, Fastify `0.0.0.0`),
|
|
175
|
+
* because BSD lets the two coexist, so it read a running phone-on-wifi dev
|
|
176
|
+
* server as absent. Linux refuses the second bind, which is why CI never saw
|
|
177
|
+
* it. A server that answers is running on any OS. What could still fool it: a
|
|
178
|
+
* server bound only to a non-loopback address, which `host` does not reach.
|
|
179
|
+
*/
|
|
180
|
+
async function portsAnswering(ports, host = '127.0.0.1') {
|
|
181
|
+
const results = await Promise.all(ports.map((p) => checkListening(p, host)))
|
|
182
|
+
return ports.filter((_, i) => results[i])
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Which of `ports` are taken for a new listener on `host` — "may the proxy
|
|
187
|
+
* have these?". Busy when the bind fails OR something already answers.
|
|
188
|
+
*
|
|
189
|
+
* The bind alone is not enough on macOS: beside a wildcard listener it
|
|
190
|
+
* succeeds, and the proxy would then own `localhost` while LAN clients still
|
|
191
|
+
* reach the old server — two apps behind one port number. The connect catches
|
|
192
|
+
* that. SEQUENTIAL, bind first: the bind probe's own listener is closed before
|
|
193
|
+
* the connect runs, so the connect can never find the probe itself.
|
|
194
|
+
*/
|
|
195
|
+
async function portsHeld(ports, host = '127.0.0.1') {
|
|
196
|
+
const bound = await portsInUse(ports, host)
|
|
197
|
+
const answering = await portsAnswering(ports, host)
|
|
198
|
+
return ports.filter((p) => bound.includes(p) || answering.includes(p))
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
module.exports = {
|
|
202
|
+
renderRoutes,
|
|
203
|
+
createRouteServer,
|
|
204
|
+
startProxy,
|
|
205
|
+
portsInUse,
|
|
206
|
+
portsInUseOn,
|
|
207
|
+
portsAnswering,
|
|
208
|
+
portsHeld,
|
|
209
|
+
waitListening,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Entry point: run as a detached process by the CLI. Reads its routes from a
|
|
213
|
+
// file so a re-`connect` just rewrites the file and restarts this (tiny) process.
|
|
214
|
+
if (require.main === module) {
|
|
215
|
+
const routesFile = process.argv[2]
|
|
216
|
+
if (!routesFile) {
|
|
217
|
+
process.stderr.write('proxy: usage: node proxy.js <routesFile>\n')
|
|
218
|
+
process.exit(1)
|
|
219
|
+
}
|
|
220
|
+
const routes = JSON.parse(fs.readFileSync(routesFile, 'utf-8'))
|
|
221
|
+
const { close } = startProxy(routes)
|
|
222
|
+
const shutdown = () => close().then(() => process.exit(0))
|
|
223
|
+
process.on('SIGTERM', shutdown)
|
|
224
|
+
process.on('SIGINT', shutdown)
|
|
225
|
+
}
|