@skitterbyte/skitterspec-jira 1.0.0 → 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 +178 -1
- package/README.md +3 -1
- package/assets/core/SETUP.md +1 -1
- package/assets/core/env.config.md +54 -2
- package/assets/core/jira.config.md +33 -6
- package/assets/hooks/main-guard.cjs +1 -1
- package/assets/hooks/review-gate.cjs +1 -1
- package/assets/rules/spec-planning.md +13 -3
- package/assets/rules/spec-reports.md +19 -4
- package/assets/skills/no-spec/SKILL.md +6 -3
- package/assets/skills/spec-bug/SKILL.md +3 -1
- package/assets/skills/spec-complete/SKILL.md +6 -3
- package/assets/skills/spec-hotfix/SKILL.md +3 -1
- package/assets/skills/spec-jira-setup/SKILL.md +2 -1
- package/assets/skills/spec-next/SKILL.md +3 -1
- package/assets/skills/spec-status/SKILL.md +27 -25
- package/assets/skills/spec-sync/SKILL.md +6 -4
- package/assets/skills/spec-to-main/SKILL.md +5 -3
- package/bin/skitterspec-jira.js +7 -0
- package/package.json +1 -1
- package/src/cli.js +82 -16
- package/src/env/config.js +111 -14
- package/src/env/glob.js +34 -0
- package/src/env/hotfix.js +33 -5
- package/src/env/integrate.js +52 -11
- package/src/env/live.js +1 -26
- package/src/env/proxy.js +33 -0
- package/src/env/review.js +47 -2
- package/src/init.js +3 -3
- package/src/vendor/jira/cli-sync.js +5 -9
- package/src/vendor/jira/config.js +5 -15
- package/src/vendor/provider-kit/src/apply.js +10 -0
- package/src/vendor/provider-kit/src/assignment.js +277 -0
- package/src/vendor/provider-kit/src/config.js +101 -48
- package/src/vendor/provider-kit/src/http.js +30 -11
- 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/released.js +80 -87
- package/src/vendor/provider-kit/src/specs.js +89 -7
- package/src/vendor/sync-core/index.js +5 -5
- package/src/vendor/sync-core/src/compare.js +38 -6
- package/src/vendor/sync-core/src/normalize.js +40 -58
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: spec-status
|
|
3
|
-
description: Show a spec's one-way sync status against Jira — a read-only
|
|
3
|
+
description: Show a spec's one-way sync status against Jira — a read-only report naming what would push, and whether someone edited the issue's description since the last push. Changes nothing. Opt-in — needs specs/.core/jira.config.json. Use when the user says "/spec-status", "is this spec in sync with Jira", "what would push", or "show spec sync status".
|
|
4
4
|
disable-model-invocation: true
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
# /spec-status — one-way sync
|
|
7
|
+
# /spec-status — one-way sync report
|
|
8
8
|
|
|
9
9
|
> Stay silent while this runs — speak only to ask something you cannot answer
|
|
10
10
|
> yourself, or to report a failure at the moment it happens. Read
|
|
@@ -15,13 +15,17 @@ Read-only. Reports two things and writes nothing:
|
|
|
15
15
|
|
|
16
16
|
1. **Pending push** — has the spec changed since the last push (is there an issue
|
|
17
17
|
description/state or any phase subtask to create or update)?
|
|
18
|
-
2. **
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
2. **Description edited** — did someone edit the issue's description on Jira
|
|
19
|
+
since the last push? The next push replaces it, so this is worth reading
|
|
20
|
+
first.
|
|
21
|
+
|
|
22
|
+
**Status is not compared, deliberately.** Skitterspec tells Jira about its
|
|
23
|
+
own transitions and never reads a status back: a card someone moved by hand is
|
|
24
|
+
theirs to have moved, and push resends State only when the spec itself moves.
|
|
21
25
|
|
|
22
26
|
The repo is the source of truth; Jira is a generated mirror, so there is no
|
|
23
|
-
per-field "conflict" — only "what would the next push send" and "did
|
|
24
|
-
|
|
27
|
+
per-field "conflict" — only "what would the next push send" and "did someone
|
|
28
|
+
write over the mirror's text".
|
|
25
29
|
|
|
26
30
|
**Opt-in**: only runs when `specs/.core/jira.config.json` exists. If absent,
|
|
27
31
|
tell the user how to enable Jira sync and stop.
|
|
@@ -30,18 +34,16 @@ tell the user how to enable Jira sync and stop.
|
|
|
30
34
|
|
|
31
35
|
Use the argument, else the spec in context; ask if unclear.
|
|
32
36
|
|
|
33
|
-
## 2. Fetch the Jira issue (optional, for
|
|
37
|
+
## 2. Fetch the Jira issue (optional, for the description check)
|
|
34
38
|
|
|
35
39
|
If the spec has a `jira_identifier`, discover the Jira MCP read tool
|
|
36
40
|
(`getJiraIssue`) and write the issue JSON to a temp file. If Jira isn't
|
|
37
|
-
connected, skip the
|
|
41
|
+
connected, skip the description check (still report pending-push).
|
|
38
42
|
|
|
39
|
-
**Write the whole issue, including its `description`.**
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
loss is silent. Keep `url` too: it is what the description line points the
|
|
44
|
-
reader at.
|
|
43
|
+
**Write the whole issue, including its `description`.** That is the field the
|
|
44
|
+
check reads; without it the check has nothing to look at — and it says nothing
|
|
45
|
+
rather than guessing, so the loss is silent. Keep `url` too: it is what the
|
|
46
|
+
description line points the reader at.
|
|
45
47
|
|
|
46
48
|
**The description itself never enters the conversation.** The engine hashes it
|
|
47
49
|
and compares hashes; what it prints is *that* the text changed, plus where to
|
|
@@ -58,8 +60,9 @@ skitterspec spec-sync status <spec> [--remote <issuefile>] [--workspace-states <
|
|
|
58
60
|
```
|
|
59
61
|
|
|
60
62
|
- Reports `push: pending — N to create, M to update` or `up to date`.
|
|
61
|
-
- With `--remote`, adds a `drift:` line
|
|
62
|
-
|
|
63
|
+
- With `--remote`, adds a `drift:` line when the issue's description was
|
|
64
|
+
edited on Jira since the last push, and nothing otherwise. It never
|
|
65
|
+
reports the issue's state.
|
|
63
66
|
- With `--workspace-states`, fails loudly if a configured status name isn't in
|
|
64
67
|
the project (no workflow transition could ever reach it, so the push would
|
|
65
68
|
refuse at apply time).
|
|
@@ -88,18 +91,17 @@ the shape; this section carries only what is specific here.
|
|
|
88
91
|
**Verdicts**
|
|
89
92
|
|
|
90
93
|
- `✅` — in sync; nothing would push.
|
|
91
|
-
- `⚠️` —
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
description means a person wrote something that the next push replaces.
|
|
94
|
+
- `⚠️` — N objects would push, or the issue's **description** was edited on
|
|
95
|
+
Jira since the last push. Neither is an error, but both are the reason
|
|
96
|
+
someone ran this. **Say which**, since they call for different things: a
|
|
97
|
+
pending push is routine, and an edited description means a person wrote
|
|
98
|
+
something that the next push replaces.
|
|
97
99
|
- `⏸` — no config, or the spec is not linked. Nothing to compare.
|
|
98
100
|
|
|
99
101
|
**Fields:** `Tracker` · `Follow-ups` · `Next`
|
|
100
102
|
|
|
101
|
-
`Tracker` is the
|
|
102
|
-
|
|
103
|
+
`Tracker` is the answer in one line — what would push, and whether the
|
|
104
|
+
description was edited. `Next` is `/spec-push` when a push is pending, and nothing to
|
|
103
105
|
do when it is not.
|
|
104
106
|
|
|
105
107
|
A **`phases: <mode>`** line names the phase mode that resolved for this spec's
|
|
@@ -44,7 +44,7 @@ In a project that installs the Jira superset the binary is
|
|
|
44
44
|
| "link this spec to PROJ-1 by hand" | `stamp <spec> --issue PROJ-1` |
|
|
45
45
|
| "mirror the whole backlog / every complete spec" | `apply --all <bucket>` — **confirm first** |
|
|
46
46
|
| "what ticket am I on?", writing a commit | `ref` |
|
|
47
|
-
| "what shipped in this release?" | `released` |
|
|
47
|
+
| "what shipped in this release?", "which tickets does CI mark deployed?" | `released` — `--json` for a script; recording deploys is the project's own |
|
|
48
48
|
| "is any of this wired up?", "why isn't sync working?" | `doctor` — scaffold, isolation, tracker and credentials in one |
|
|
49
49
|
| "set my Jira credentials", "am I authenticated?" | `credentials <status\|set\|unset>` |
|
|
50
50
|
| push one spec, or "what would push?" | **defer** — see below |
|
|
@@ -66,7 +66,7 @@ Two front doors to one write path is worse than none:
|
|
|
66
66
|
|
|
67
67
|
- **Push one spec** → `/spec-push`. It owns the transport choice, the
|
|
68
68
|
status-name gate, the plan, the apply, the stamping and the snapshot.
|
|
69
|
-
- **Per-spec
|
|
69
|
+
- **Per-spec status** ("is this spec in sync?", "what would push?") →
|
|
70
70
|
`/spec-status`.
|
|
71
71
|
|
|
72
72
|
Say which skill you're handing to and stop; don't reimplement either.
|
|
@@ -170,8 +170,10 @@ and never write `Refs: none`.
|
|
|
170
170
|
|
|
171
171
|
`released` reports the tickets in a commit range — by default since the most
|
|
172
172
|
recent tag, and it always prints the range it chose so a wrong default is
|
|
173
|
-
visible. It is **read-only**: it does not move anything in Jira.
|
|
174
|
-
|
|
173
|
+
visible. It is **read-only**: it does not move anything in Jira. Each ticket
|
|
174
|
+
row names the `spec` and `url` behind it (`null` when no spec linked the ref),
|
|
175
|
+
so `--json` is what a project's CI pipes into its own tooling to record a
|
|
176
|
+
release or a deploy. Recording those is the project's job, not skitterspec's.
|
|
175
177
|
|
|
176
178
|
Relay the **unreferenced count** even when it is zero. A chore commit
|
|
177
179
|
legitimately carries no ticket and a *missed* trailer looks identical, so
|
|
@@ -91,7 +91,9 @@ and continue — the branch has no commits base doesn't already have.
|
|
|
91
91
|
`then, in the primary checkout (<path>), run:` section — the project's
|
|
92
92
|
`afterLand`, such as a data migration — run those commands in that directory,
|
|
93
93
|
in order, after the fast-forward and **before** the re-test below, which may
|
|
94
|
-
need them. Run
|
|
94
|
+
need them. Run only the lines under that heading; a
|
|
95
|
+
`skipped (nothing in the land matched):` block lists conditional entries this
|
|
96
|
+
land did not need, and is information, never commands. Run them on the no-op too: the engine prints them there because a
|
|
95
97
|
land made elsewhere leaves them pending here.
|
|
96
98
|
**Stop at the first non-zero exit**, and carry on with §5: a failure makes the run `⚠️`, naming the command
|
|
97
99
|
that failed and the ones that did not run, and it **never undoes the land**.
|
|
@@ -129,8 +131,8 @@ the shape; this section carries only what is specific here.
|
|
|
129
131
|
|
|
130
132
|
**Fields:** `Branch` · `Tests` · `Notes` · `Landed` · `Follow-ups` · `Next`
|
|
131
133
|
|
|
132
|
-
`Notes` carries the after-land commands §4 ran, and where
|
|
133
|
-
failed and the ones that did not run. Nothing about it when none are
|
|
134
|
+
`Notes` carries the after-land commands §4 ran, and where, and any it
|
|
135
|
+
skipped because the land matched no `when` — or the one that failed and the ones that did not run. Nothing about it when none are
|
|
134
136
|
configured.
|
|
135
137
|
|
|
136
138
|
**No `Spec` field, deliberately.** This skill changes no status and moves no
|
package/bin/skitterspec-jira.js
CHANGED
|
@@ -20,6 +20,13 @@ if (!existsSync(join(__dirname, '..', 'src'))) {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
const { run, HELP } = require('../src/cli.js')
|
|
23
|
+
|
|
24
|
+
// The provider's isolation defaults, registered beneath the project's own
|
|
25
|
+
// env.config.json so `spec-env stage` claims this provider's snapshot sidecar
|
|
26
|
+
// without a hand edit (see common src/env/config.js, setProviderEnvDefaults).
|
|
27
|
+
require('../src/env/config.js').setProviderEnvDefaults(
|
|
28
|
+
require('../src/vendor/jira/config.js').ENV_CONFIG_DEFAULTS,
|
|
29
|
+
)
|
|
23
30
|
const {
|
|
24
31
|
PROVIDER_COMMANDS,
|
|
25
32
|
providerHelpSection,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skitterbyte/skitterspec-jira",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Spec-driven development for Claude Code, with one-way Jira Cloud sync — a superset of @skitterbyte/skitterspec: the base filesystem workflow plus /spec-status · /spec-push and the spec-sync CLI. The repo is canonical; Jira is a generated mirror. Install this OR the base OR the Linear superset — exactly one.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/src/cli.js
CHANGED
|
@@ -88,6 +88,8 @@ const {
|
|
|
88
88
|
collectReview,
|
|
89
89
|
reviewTierStack,
|
|
90
90
|
reviewTierLine,
|
|
91
|
+
isTailnetAddress,
|
|
92
|
+
tailnetLabel,
|
|
91
93
|
renderReviewPage,
|
|
92
94
|
renderReviewBlock,
|
|
93
95
|
reviewOutPath,
|
|
@@ -165,7 +167,13 @@ const { planHotfixLand, planHotfixFinish } = require('./env/hotfix.js')
|
|
|
165
167
|
const { runHotfixLand, describeStep: describeHotfixStep } = require('./env/hotfixland.js')
|
|
166
168
|
const { planDev } = require('./env/dev.js')
|
|
167
169
|
const { startProcess, stopProcess, waitHealthy, readPid, isAlive } = require('./env/supervise.js')
|
|
168
|
-
const {
|
|
170
|
+
const {
|
|
171
|
+
renderRoutes,
|
|
172
|
+
portsHeld,
|
|
173
|
+
portsAnswering,
|
|
174
|
+
portsInUseOn,
|
|
175
|
+
waitListening,
|
|
176
|
+
} = require('./env/proxy.js')
|
|
169
177
|
const {
|
|
170
178
|
mintToken,
|
|
171
179
|
servableSpecs,
|
|
@@ -185,7 +193,7 @@ const pkg = require('../package.json')
|
|
|
185
193
|
// supersets the hand-off names both where both ship the command, and the one
|
|
186
194
|
// that does where only one does.
|
|
187
195
|
const PROVIDER_COMMANDS = {
|
|
188
|
-
'spec-sync': '@skitterbyte/skitterspec-linear or @skitterbyte/skitterspec-
|
|
196
|
+
'spec-sync': '@skitterbyte/skitterspec-linear, @skitterbyte/skitterspec-jira, @skitterbyte/skitterspec-github or @skitterbyte/skitterspec-azure-boards',
|
|
189
197
|
'spec-sanitise': '@skitterbyte/skitterspec-linear',
|
|
190
198
|
}
|
|
191
199
|
|
|
@@ -1914,6 +1922,7 @@ function specEnvIntegrate(dir, config, specArg) {
|
|
|
1914
1922
|
aheadOfBase: cahead !== null && Number(cahead) > 0,
|
|
1915
1923
|
checkoutPath: dir,
|
|
1916
1924
|
onBranch: cgit(['rev-parse', '--abbrev-ref', 'HEAD']) === spec.branch,
|
|
1925
|
+
changedFiles: landRange(cgit, base, spec.branch),
|
|
1917
1926
|
})
|
|
1918
1927
|
if (cplan.blocked) {
|
|
1919
1928
|
process.stdout.write(`spec-env integrate: blocked — ${cplan.reason}.\n`)
|
|
@@ -2011,6 +2020,7 @@ function specEnvIntegrate(dir, config, specArg) {
|
|
|
2011
2020
|
base,
|
|
2012
2021
|
aheadOfBase,
|
|
2013
2022
|
mainRepoPath: dir,
|
|
2023
|
+
changedFiles: landRange(gitReader(dir), base, spec.branch),
|
|
2014
2024
|
})
|
|
2015
2025
|
|
|
2016
2026
|
if (plan.blocked) {
|
|
@@ -2037,13 +2047,47 @@ function specEnvIntegrate(dir, config, specArg) {
|
|
|
2037
2047
|
process.stdout.write(out.join('\n') + '\n' + afterLandSection(plan.afterLand))
|
|
2038
2048
|
}
|
|
2039
2049
|
|
|
2050
|
+
// The files a land brings into the base branch: `base...branch`, merge-base to
|
|
2051
|
+
// the branch tip. Read before the rebase runs, so it is the branch's own
|
|
2052
|
+
// changes — `base..branch` would also list what the base gained meanwhile and
|
|
2053
|
+
// over-match. A diff git would not answer is null: unknown, never empty.
|
|
2054
|
+
function landRange(git, base, branch) {
|
|
2055
|
+
const out = git(['diff', '--name-only', `${base}...${branch}`])
|
|
2056
|
+
if (out === null) return null
|
|
2057
|
+
return out.split('\n').map((l) => l.trim()).filter(Boolean)
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2040
2060
|
// The `afterLand` commands, as a section printed after a land plan — or after
|
|
2041
2061
|
// integrate's already-landed line. Empty string when none apply, so a project
|
|
2042
|
-
// without the key sees exactly the output it always has
|
|
2062
|
+
// without the key sees exactly the output it always has, and one of plain
|
|
2063
|
+
// strings sees exactly what it saw before entries could be conditional.
|
|
2043
2064
|
function afterLandSection(afterLand) {
|
|
2044
|
-
if (!afterLand || !afterLand.
|
|
2045
|
-
const
|
|
2046
|
-
|
|
2065
|
+
if (!afterLand || !afterLand.entries || afterLand.entries.length === 0) return ''
|
|
2066
|
+
const entries = afterLand.entries
|
|
2067
|
+
const ran = entries.filter((e) => e.decision !== 'skipped')
|
|
2068
|
+
const skipped = entries.filter((e) => e.decision === 'skipped')
|
|
2069
|
+
const width = Math.max(...entries.map((e) => e.run.length))
|
|
2070
|
+
const out = ['']
|
|
2071
|
+
if (ran.length) {
|
|
2072
|
+
out.push(` then, in the primary checkout (${afterLand.cwd}), run (stop at the first failure):`)
|
|
2073
|
+
for (const e of ran) {
|
|
2074
|
+
// The reason is a shell COMMENT, not prose after a dash: these lines are
|
|
2075
|
+
// run as printed, and `cmd — path changed` would hand the command two
|
|
2076
|
+
// stray arguments.
|
|
2077
|
+
if (e.decision === 'matched') out.push(` ${e.run.padEnd(width)} # ${e.matched} changed`)
|
|
2078
|
+
else if (e.decision === 'unknown') out.push(` ${e.run.padEnd(width)} # range unknown`)
|
|
2079
|
+
else out.push(` ${e.run}`)
|
|
2080
|
+
}
|
|
2081
|
+
} else {
|
|
2082
|
+
out.push(` after-land, in the primary checkout (${afterLand.cwd}): nothing to run.`)
|
|
2083
|
+
}
|
|
2084
|
+
if (ran.some((e) => e.decision === 'unknown')) {
|
|
2085
|
+
out.push(" the land's changed files are unknown here, so the conditional entries run.")
|
|
2086
|
+
}
|
|
2087
|
+
if (skipped.length) {
|
|
2088
|
+
out.push(' skipped (nothing in the land matched):')
|
|
2089
|
+
for (const e of skipped) out.push(` ${e.run.padEnd(width)} # when ${e.when.join(', ')}`)
|
|
2090
|
+
}
|
|
2047
2091
|
return out.join('\n') + '\n'
|
|
2048
2092
|
}
|
|
2049
2093
|
|
|
@@ -2105,6 +2149,12 @@ function hotfixCarry(dir, spec, base, wtGit, released) {
|
|
|
2105
2149
|
.map((l) => l.slice(2).trim()),
|
|
2106
2150
|
)
|
|
2107
2151
|
const commits = fixCommits.filter((sha) => !already.has(sha))
|
|
2152
|
+
// What main gains from the whole fix, for afterLand's `when` — taken from
|
|
2153
|
+
// every fix commit rather than the remaining ones, so a carry resumed after a
|
|
2154
|
+
// partial run still sees what the earlier picks changed. A commit git would
|
|
2155
|
+
// not describe makes the range unknown (null), which runs the conditional
|
|
2156
|
+
// entries rather than skipping them.
|
|
2157
|
+
const files = carryFiles(wtGit, fixCommits)
|
|
2108
2158
|
|
|
2109
2159
|
// Which checkout, if any, has the base branch checked out.
|
|
2110
2160
|
const list = git(['worktree', 'list', '--porcelain']) || ''
|
|
@@ -2117,14 +2167,24 @@ function hotfixCarry(dir, spec, base, wtGit, released) {
|
|
|
2117
2167
|
let carry
|
|
2118
2168
|
if (where) {
|
|
2119
2169
|
const st = gitReader(where)(['status', '--porcelain'])
|
|
2120
|
-
carry = { strategy: st ? 'dirty' : 'inplace', path: where, commits }
|
|
2170
|
+
carry = { strategy: st ? 'dirty' : 'inplace', path: where, commits, files }
|
|
2121
2171
|
} else {
|
|
2122
2172
|
const tmpPath = path.join(path.dirname(spec.worktreePath), `${spec.slug}-carry-${base}`)
|
|
2123
|
-
carry = { strategy: 'worktree', path: dir, tmpPath, commits }
|
|
2173
|
+
carry = { strategy: 'worktree', path: dir, tmpPath, commits, files }
|
|
2124
2174
|
}
|
|
2125
2175
|
return { fixCommits, carry }
|
|
2126
2176
|
}
|
|
2127
2177
|
|
|
2178
|
+
function carryFiles(git, commits) {
|
|
2179
|
+
const files = new Set()
|
|
2180
|
+
for (const sha of commits) {
|
|
2181
|
+
const out = git(['diff-tree', '--no-commit-id', '--name-only', '-r', sha])
|
|
2182
|
+
if (out === null) return null
|
|
2183
|
+
for (const line of out.split('\n')) if (line.trim()) files.add(line.trim())
|
|
2184
|
+
}
|
|
2185
|
+
return [...files]
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2128
2188
|
// `hotfix finish`: tear a landed hotfix down once its deploy is out. The guards
|
|
2129
2189
|
// live in `planHotfixFinish`; this gathers the facts, refuses from inside the
|
|
2130
2190
|
// worktree it is about to remove, and runs the two git commands itself.
|
|
@@ -4200,6 +4260,10 @@ async function specEnvReview(dir, config, specArg, flags, invokedFrom = dir) {
|
|
|
4200
4260
|
// use, and the engine cannot know — that guess failed three separate ways
|
|
4201
4261
|
// in one day. Now every tier is listed, labelled, and either a URL or the
|
|
4202
4262
|
// one command that turns it on.
|
|
4263
|
+
// The runners-up print as `also:` lines inside the network tier's own
|
|
4264
|
+
// rendering — `reviewTierLine` carries them, which is what puts them
|
|
4265
|
+
// under `network:` rather than dangling after the stack (they used to
|
|
4266
|
+
// print below `remote:`, against the comment that placed them).
|
|
4203
4267
|
reviewTierStack({
|
|
4204
4268
|
served,
|
|
4205
4269
|
fileUrl: reviewFileUrl(out),
|
|
@@ -4208,11 +4272,6 @@ async function specEnvReview(dir, config, specArg, flags, invokedFrom = dir) {
|
|
|
4208
4272
|
})
|
|
4209
4273
|
.map((t) => reviewTierLine(t) + '\n')
|
|
4210
4274
|
.join('') +
|
|
4211
|
-
// The runners-up sit UNDER the network tier, because that is what they are
|
|
4212
|
-
// alternatives to — and virtual adapters are gone from them entirely.
|
|
4213
|
-
(served && !served.loopback && (served.alternates || []).length
|
|
4214
|
-
? served.alternates.map((u) => ` also: ${u}\n`).join('')
|
|
4215
|
-
: '') +
|
|
4216
4275
|
// WHICH TIERS THE WAIT COVERS, once, next to the stack. `local` and
|
|
4217
4276
|
// `network` are two doors into one room — the page POSTs to
|
|
4218
4277
|
// `location.pathname`, so both reach this server and this pending store.
|
|
@@ -4514,8 +4573,14 @@ function lanAddresses(nets = require('node:os').networkInterfaces()) {
|
|
|
4514
4573
|
* where the opposite hides the only address that works.
|
|
4515
4574
|
*/
|
|
4516
4575
|
function offerableLanAddresses(nets = require('node:os').networkInterfaces()) {
|
|
4517
|
-
|
|
4576
|
+
const ranked = rankLanAddresses(nets)
|
|
4577
|
+
// A tailnet address IS reachable from a phone off the LAN, but macOS puts it
|
|
4578
|
+
// on `utunN`, which the virtual filter drops. It is appended after every
|
|
4579
|
+
// other offerable address so the wifi one stays the best guess at home.
|
|
4580
|
+
const tailnet = ranked.filter((e) => VIRTUAL_IFACE.test(e.iface) && isTailnetAddress(e.address))
|
|
4581
|
+
return ranked
|
|
4518
4582
|
.filter((e) => !VIRTUAL_IFACE.test(e.iface))
|
|
4583
|
+
.concat(tailnet)
|
|
4519
4584
|
.map((e) => e.address)
|
|
4520
4585
|
}
|
|
4521
4586
|
|
|
@@ -4988,7 +5053,7 @@ async function specEnvConnect(dir, config, specArg) {
|
|
|
4988
5053
|
// Stop any proxy we already run (a previous connect), freeing the canonical
|
|
4989
5054
|
// ports, then refuse if the primary checkout still holds one of them.
|
|
4990
5055
|
await stopProcess(proxyProc, { rootDir: dir })
|
|
4991
|
-
const busy = await
|
|
5056
|
+
const busy = await portsHeld(routes.map((r) => r.frontPort), config.proxy.host)
|
|
4992
5057
|
if (busy.length) {
|
|
4993
5058
|
process.stdout.write(
|
|
4994
5059
|
`spec-connect: canonical port(s) ${busy.join(', ')} are in use (your main dev server?).\n` +
|
|
@@ -5199,7 +5264,7 @@ async function specEnvLiveTake(dir, config, specArg, flags = {}) {
|
|
|
5199
5264
|
const canonicalPorts = config.dev.map((d) => d.frontPort).filter((p) => typeof p === 'number')
|
|
5200
5265
|
let serverUp = null
|
|
5201
5266
|
if (canonicalPorts.length) {
|
|
5202
|
-
const up = await
|
|
5267
|
+
const up = await portsAnswering(canonicalPorts, config.proxy.host)
|
|
5203
5268
|
serverUp = up.length === canonicalPorts.length
|
|
5204
5269
|
}
|
|
5205
5270
|
|
|
@@ -5892,6 +5957,7 @@ module.exports = {
|
|
|
5892
5957
|
unknownCommand,
|
|
5893
5958
|
rankLanAddresses,
|
|
5894
5959
|
offerableLanAddresses,
|
|
5960
|
+
tailnetLabel,
|
|
5895
5961
|
reviewTierStack,
|
|
5896
5962
|
serveProcFor,
|
|
5897
5963
|
serverScriptOk,
|
package/src/env/config.js
CHANGED
|
@@ -22,11 +22,16 @@
|
|
|
22
22
|
* seedFiles:{ mode, files } | [ ".env", ... ], // gitignored files copied/
|
|
23
23
|
* // symlinked from the main checkout into a fresh worktree before
|
|
24
24
|
* // setup runs (mode: "symlink" default | "copy"); empty = none
|
|
25
|
-
* setup: [ "cmd", ... ], // bootstrap commands run in
|
|
26
|
-
* // after `git worktree add` (e.g. install
|
|
27
|
-
*
|
|
28
|
-
* //
|
|
29
|
-
*
|
|
25
|
+
* setup: [ "cmd" | { run, when }, ... ], // bootstrap commands run in
|
|
26
|
+
* // the worktree right after `git worktree add` (e.g. install
|
|
27
|
+
* // deps); `when` is ignored there — normalised to strings, with
|
|
28
|
+
* // the triggered entries also kept as `setupTriggers`
|
|
29
|
+
* afterLand:[ "cmd" | { run, when } | { setup: true }, ... ], // commands
|
|
30
|
+
* // run in the PRIMARY checkout after a land (integrate's ff,
|
|
31
|
+
* // hotfix land's main cherry-pick), e.g. a data migration;
|
|
32
|
+
* // `when` globs gate an entry on the land's changed files, and
|
|
33
|
+
* // `{ setup: true }` expands to `setupTriggers`; literal, no
|
|
34
|
+
* // token expansion; normalised to `{ run, when }`; empty = none
|
|
30
35
|
* dev: [ { name, command, portVar, health?, frontPort? } ], // host dev
|
|
31
36
|
* // servers started on the spec's port block (empty = none)
|
|
32
37
|
* proxy: { enabled, host }, // bundled front-door proxy (spec-env connect)
|
|
@@ -169,11 +174,16 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
169
174
|
// `git worktree add` (before Docker/dev), on every provision. Array of shell
|
|
170
175
|
// strings (e.g. "pnpm install"); {slug}/{branch}/… expand. Default: none.
|
|
171
176
|
setup: Object.freeze([]),
|
|
177
|
+
// The `setup` entries that declared `when` globs, as `{ run, when }`. Derived
|
|
178
|
+
// at load, never configured directly: `afterLand`'s `{ setup: true }` expands
|
|
179
|
+
// to these, so a setup step can re-run after a land that changes its inputs.
|
|
180
|
+
setupTriggers: Object.freeze([]),
|
|
172
181
|
// Commands run in the primary checkout after a land, printed by `spec-env
|
|
173
182
|
// integrate` (including its already-landed no-op) and by `hotfix land` when
|
|
174
183
|
// it cherry-picks onto the base branch. The skill runs them; the engine only
|
|
175
184
|
// plans. Literal strings — no {slug}/{port} tokens, since they run on the base
|
|
176
|
-
// branch where a spec's tokens mean nothing.
|
|
185
|
+
// branch where a spec's tokens mean nothing. Normalised to `{ run, when }`,
|
|
186
|
+
// `when` null for an always-run entry. Default: none.
|
|
177
187
|
afterLand: Object.freeze([]),
|
|
178
188
|
// Host dev servers started on the spec's port block by `spec-env dev up`.
|
|
179
189
|
// Each: { name, command, portVar, health?, frontPort? }. Default: none.
|
|
@@ -352,6 +362,7 @@ function defaults() {
|
|
|
352
362
|
docker: { ...DEFAULT_CONFIG.docker },
|
|
353
363
|
seedFiles: { mode: DEFAULT_CONFIG.seedFiles.mode, files: [] },
|
|
354
364
|
setup: [],
|
|
365
|
+
setupTriggers: [],
|
|
355
366
|
afterLand: [],
|
|
356
367
|
dev: [],
|
|
357
368
|
proxy: { ...DEFAULT_CONFIG.proxy },
|
|
@@ -524,11 +535,49 @@ function normalizeSeedFiles(parsed) {
|
|
|
524
535
|
* stray entry can't crash provisioning.
|
|
525
536
|
*/
|
|
526
537
|
function normalizeSetup(parsed) {
|
|
538
|
+
return normalizeCommands(parsed).map((e) => e.run)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Normalise a list of command entries into `{ run, when }`. A string is an
|
|
543
|
+
* always-run entry (`when: null`); `{ run, when }` keeps its trigger globs, with
|
|
544
|
+
* a string `when` coerced to a one-item list. An object whose `run` is blank, or
|
|
545
|
+
* whose `when` holds no usable glob, is dropped rather than demoted to
|
|
546
|
+
* always-run — a malformed trigger must not quietly widen into "every time".
|
|
547
|
+
*/
|
|
548
|
+
function normalizeCommands(parsed) {
|
|
527
549
|
const out = []
|
|
528
550
|
for (const raw of parsed) {
|
|
529
|
-
if (typeof raw
|
|
530
|
-
|
|
531
|
-
|
|
551
|
+
if (typeof raw === 'string') {
|
|
552
|
+
const run = raw.trim()
|
|
553
|
+
if (run) out.push({ run, when: null })
|
|
554
|
+
continue
|
|
555
|
+
}
|
|
556
|
+
if (!isObject(raw) || typeof raw.run !== 'string') continue
|
|
557
|
+
const run = raw.run.trim()
|
|
558
|
+
if (!run) continue
|
|
559
|
+
if (raw.when === undefined) {
|
|
560
|
+
out.push({ run, when: null })
|
|
561
|
+
continue
|
|
562
|
+
}
|
|
563
|
+
const globs = typeof raw.when === 'string' ? [raw.when] : Array.isArray(raw.when) ? raw.when : []
|
|
564
|
+
const when = normalizeFileList(globs)
|
|
565
|
+
if (when.length) out.push({ run, when })
|
|
566
|
+
}
|
|
567
|
+
return out
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// `afterLand` entries: `normalizeCommands`, plus `{ setup: true }` expanded in
|
|
571
|
+
// place to the triggered setup entries. With none declared it expands to
|
|
572
|
+
// nothing — the shorthand names a set, and an empty set is a valid one.
|
|
573
|
+
function normalizeAfterLand(parsed, setupTriggers) {
|
|
574
|
+
const out = []
|
|
575
|
+
for (const raw of parsed) {
|
|
576
|
+
if (isObject(raw) && raw.setup === true) {
|
|
577
|
+
for (const t of setupTriggers) out.push({ run: t.run, when: [...t.when] })
|
|
578
|
+
continue
|
|
579
|
+
}
|
|
580
|
+
out.push(...normalizeCommands([raw]))
|
|
532
581
|
}
|
|
533
582
|
return out
|
|
534
583
|
}
|
|
@@ -560,12 +609,15 @@ function mergeConfig(base, parsed) {
|
|
|
560
609
|
}
|
|
561
610
|
|
|
562
611
|
if (Array.isArray(parsed.setup)) {
|
|
563
|
-
|
|
612
|
+
const entries = normalizeCommands(parsed.setup)
|
|
613
|
+
base.setup = entries.map((e) => e.run)
|
|
614
|
+
base.setupTriggers = entries.filter((e) => e.when)
|
|
564
615
|
}
|
|
565
616
|
|
|
566
|
-
// Same rule as `setup`: a stray entry is dropped, never thrown on.
|
|
617
|
+
// Same rule as `setup`: a stray entry is dropped, never thrown on. Read after
|
|
618
|
+
// `setup`, which `{ setup: true }` expands from.
|
|
567
619
|
if (Array.isArray(parsed.afterLand)) {
|
|
568
|
-
base.afterLand =
|
|
620
|
+
base.afterLand = normalizeAfterLand(parsed.afterLand, base.setupTriggers)
|
|
569
621
|
}
|
|
570
622
|
|
|
571
623
|
if (Array.isArray(parsed.dev)) {
|
|
@@ -707,6 +759,48 @@ function mergeConfig(base, parsed) {
|
|
|
707
759
|
* It is advisory in the strongest sense: nothing here acts on it, and a config
|
|
708
760
|
* full of strays loads exactly as it always did.
|
|
709
761
|
*/
|
|
762
|
+
// The isolation keys a ticketing provider needs a project to declare — set
|
|
763
|
+
// once per process by a provider superset's bin, beneath whatever the
|
|
764
|
+
// project's own env.config.json says. They used to reach only the
|
|
765
|
+
// `env.config.json.example` composed at build time, which left every project
|
|
766
|
+
// that adopted a provider over an EXISTING isolation config (or skipped the
|
|
767
|
+
// example) with a `spec.companionPaths` that never claims the provider's
|
|
768
|
+
// snapshot sidecar — `spec-env stage` then classifies the spec's own
|
|
769
|
+
// `<baseDir>/<identifier>.base.json` as foreign and `/commit` leaves it
|
|
770
|
+
// behind. The base distribution registers nothing and behaves as before.
|
|
771
|
+
let providerEnvDefaults = null
|
|
772
|
+
function setProviderEnvDefaults(overrides) {
|
|
773
|
+
providerEnvDefaults = overrides || null
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Fill ONLY the keys the project's file does not mention: a key the file sets —
|
|
777
|
+
// including explicitly to '' or [] — is the project's decision and stands
|
|
778
|
+
// ("the two keys are one fix" in scripts/build-dist.test.js is the contract:
|
|
779
|
+
// blanking identifierField must keep disabling the companion expansion).
|
|
780
|
+
function applyProviderEnvDefaults(config, parsed) {
|
|
781
|
+
if (!providerEnvDefaults) return config
|
|
782
|
+
const d = providerEnvDefaults
|
|
783
|
+
const pBranch = parsed && typeof parsed.branch === 'object' && parsed.branch !== null ? parsed.branch : null
|
|
784
|
+
if (
|
|
785
|
+
d.branch &&
|
|
786
|
+
typeof d.branch.identifierField === 'string' &&
|
|
787
|
+
!(pBranch && 'identifierField' in pBranch) &&
|
|
788
|
+
!config.branch.identifierField
|
|
789
|
+
) {
|
|
790
|
+
config.branch.identifierField = d.branch.identifierField
|
|
791
|
+
}
|
|
792
|
+
const pSpec = parsed && typeof parsed.spec === 'object' && parsed.spec !== null ? parsed.spec : null
|
|
793
|
+
if (
|
|
794
|
+
d.spec &&
|
|
795
|
+
Array.isArray(d.spec.companionPaths) &&
|
|
796
|
+
!(pSpec && 'companionPaths' in pSpec) &&
|
|
797
|
+
!config.spec.companionPaths.length
|
|
798
|
+
) {
|
|
799
|
+
config.spec.companionPaths = [...d.spec.companionPaths]
|
|
800
|
+
}
|
|
801
|
+
return config
|
|
802
|
+
}
|
|
803
|
+
|
|
710
804
|
function loadEnvConfig(dir = process.cwd()) {
|
|
711
805
|
const base = defaults()
|
|
712
806
|
const file = join(dir, CONFIG_FILE)
|
|
@@ -715,7 +809,9 @@ function loadEnvConfig(dir = process.cwd()) {
|
|
|
715
809
|
try {
|
|
716
810
|
raw = readFileSync(file, 'utf-8')
|
|
717
811
|
} catch (error) {
|
|
718
|
-
if (error.code === 'ENOENT')
|
|
812
|
+
if (error.code === 'ENOENT') {
|
|
813
|
+
return { config: applyProviderEnvDefaults(base, null), present: false, unknown: [], badReviewers: [] }
|
|
814
|
+
}
|
|
719
815
|
throw error
|
|
720
816
|
}
|
|
721
817
|
|
|
@@ -727,7 +823,7 @@ function loadEnvConfig(dir = process.cwd()) {
|
|
|
727
823
|
}
|
|
728
824
|
|
|
729
825
|
return {
|
|
730
|
-
config: mergeConfig(base, parsed),
|
|
826
|
+
config: applyProviderEnvDefaults(mergeConfig(base, parsed), parsed),
|
|
731
827
|
present: true,
|
|
732
828
|
unknown: collectUnknownKeys(parsed),
|
|
733
829
|
badReviewers: collectBadReviewers(parsed),
|
|
@@ -736,6 +832,7 @@ function loadEnvConfig(dir = process.cwd()) {
|
|
|
736
832
|
|
|
737
833
|
module.exports = {
|
|
738
834
|
loadEnvConfig,
|
|
835
|
+
setProviderEnvDefaults,
|
|
739
836
|
resolveServePort,
|
|
740
837
|
derivedServePort,
|
|
741
838
|
servePortRoot,
|
package/src/env/glob.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Restricted glob matching for repo-relative paths, shared by the live-overlay
|
|
5
|
+
* migration check and the conditional `afterLand` entries. Zero-dependency.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Translate a restricted glob into an anchored RegExp: `**` matches across path
|
|
9
|
+
// separators (with an optional trailing `/`), `*` matches within a segment, `?`
|
|
10
|
+
// a single non-separator char. Enough for repo-path globs, no dependency.
|
|
11
|
+
function globToRegExp(glob) {
|
|
12
|
+
let re = ''
|
|
13
|
+
for (let i = 0; i < glob.length; i++) {
|
|
14
|
+
const ch = glob[i]
|
|
15
|
+
if (ch === '*') {
|
|
16
|
+
if (glob[i + 1] === '*') {
|
|
17
|
+
re += '.*'
|
|
18
|
+
i++
|
|
19
|
+
if (glob[i + 1] === '/') i++ // consume the `/` in `**/`
|
|
20
|
+
} else {
|
|
21
|
+
re += '[^/]*'
|
|
22
|
+
}
|
|
23
|
+
} else if (ch === '?') {
|
|
24
|
+
re += '[^/]'
|
|
25
|
+
} else if ('.+^${}()|[]\\/'.includes(ch)) {
|
|
26
|
+
re += '\\' + ch
|
|
27
|
+
} else {
|
|
28
|
+
re += ch
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return new RegExp('^' + re + '$')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { globToRegExp }
|