@skitterbyte/skitterspec-linear 2.0.0 → 3.0.1

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.
@@ -12,6 +12,7 @@
12
12
  "envFile": ".env",
13
13
  "backupCommand": ""
14
14
  },
15
+ "setup": [],
15
16
  "dev": [],
16
17
  "proxy": {
17
18
  "enabled": true,
@@ -52,6 +52,16 @@ no live `env.config.json` was found.
52
52
  // empty = no backup, volumes dropped directly.
53
53
  },
54
54
 
55
+ // Bootstrap commands `spec-env up <spec>` runs IN the worktree, right after
56
+ // `git worktree add` (before Docker/dev), on every provision including
57
+ // re-attach — so a fresh worktree's dependencies exist and git hooks,
58
+ // typechecks, builds and tests work immediately instead of failing on a
59
+ // missing node_modules. An array, run in order; [] = none. Each string is a
60
+ // shell command; {slug}/{branch}/{worktreePath}/{projectName}/{portOffset}
61
+ // expand (the cwd is already the worktree, so {worktreePath} is usually
62
+ // redundant). Example: ["pnpm install --frozen-lockfile"].
63
+ "setup": [],
64
+
55
65
  // Host dev servers `spec-env dev up <spec>` starts on the spec's port block
56
66
  // (for apps that run via `pnpm dev` on the host, not inside the Docker stack).
57
67
  // An array so UI + API (or more) are supervised independently; [] = none.
@@ -30,6 +30,13 @@ housekeeping below lands on the spec's branch and never on `main`:
30
30
  worktree on a branch forked from `main`, and — only when the spec's
31
31
  `> **Stack:**` header is `worktree + docker` — also brings up its Docker stack.
32
32
  Print the worktree path and the opener command it emits.
33
+ - **Bootstrap the worktree's dependencies.** A fresh worktree has an empty
34
+ working tree — no installed dependencies — so git hooks, typechecks, builds and
35
+ tests fail until they're installed. `spec-env up` prints the project's
36
+ configured **`in the worktree, run:`** commands (from `env.config.json` →
37
+ `setup`, e.g. an install command) — run them in the worktree before doing
38
+ anything else. With no `setup` configured there's nothing to run; set one up if
39
+ agents keep stalling on missing dependencies.
33
40
  - **Trust the worktree for this session.** The engine wrote the printed
34
41
  `trusted:` root into `.claude/settings.local.json` (gitignored) so future
35
42
  sessions trust it automatically — but that file likely won't hot-reload now,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skitterbyte/skitterspec-linear",
3
- "version": "2.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "Spec-driven development for Claude Code, with Linear hybrid-sync — a superset of @skitterbyte/skitterspec: the base filesystem workflow plus git-like /spec-status · /spec-pull · /spec-push and the spec-sync CLI. Install this OR the base, not both.",
5
5
  "keywords": [
6
6
  "claude",
package/src/cli.js CHANGED
@@ -17,7 +17,14 @@ const {
17
17
  freeSlot,
18
18
  portOffset,
19
19
  } = require('./env/registry.js')
20
- const { resolveSpec, resolveBaseBranch, repoInfo, expandTokens, splitPrefix } = require('./env/resolve.js')
20
+ const {
21
+ resolveSpec,
22
+ resolveBaseBranch,
23
+ resolvePrimaryCheckout,
24
+ repoInfo,
25
+ expandTokens,
26
+ splitPrefix,
27
+ } = require('./env/resolve.js')
21
28
  const { ensureWorktreeDirTrusted } = require('./env/trust.js')
22
29
  const { planUp } = require('./env/provision.js')
23
30
  const { planDown } = require('./env/teardown.js')
@@ -205,6 +212,11 @@ function specEnvUp(dir, config, specArg) {
205
212
  out.push(' run these:')
206
213
  for (const cmd of plan.commands) out.push(` ${cmd}`)
207
214
  if (plan.openCommand) out.push(` ${plan.openCommand}`)
215
+ if (plan.setupCommands.length) {
216
+ out.push('')
217
+ out.push(' in the worktree, run:')
218
+ for (const cmd of plan.setupCommands) out.push(` ${cmd}`)
219
+ }
208
220
  if (plan.envContents) {
209
221
  out.push('')
210
222
  out.push(` write ${config.docker.envFile} in the worktree:`)
@@ -331,26 +343,23 @@ function specEnvIntegrate(dir, config, specArg) {
331
343
  return
332
344
  }
333
345
 
334
- // /spec-complete runs this from inside the worktree, but the spec's coordinates
335
- // (worktreePath via {repo}, the base branch) must resolve against the PRIMARY
336
- // checkout. Resolve it first (parent of the shared git dir) and anchor
337
- // everything to it, so integrate works whether invoked from main or a worktree.
338
- const commonDir = gitReader(dir)(['rev-parse', '--git-common-dir'])
339
- const mainRepoPath = commonDir ? path.dirname(path.resolve(dir, commonDir)) : dir
340
-
346
+ // `dir` is already anchored on the primary checkout by the dispatch, so it is
347
+ // both where the spec resolves and the target of the fast-forward — /spec-complete
348
+ // can run this from inside the worktree and still land on main.
349
+ //
341
350
  // A spec authored entirely on its branch may not exist in the primary
342
351
  // checkout's specs/** (it was never committed to base) — but its worktree
343
352
  // does, and the worktree path is derivable from config without the folder.
344
353
  // Offer it as a fallback search location so integrate can still find the spec.
345
354
  const { slug } = splitPrefix(path.basename(specArg))
346
- const { repo, repoSlug } = repoInfo(mainRepoPath)
355
+ const { repo, repoSlug } = repoInfo(dir)
347
356
  const wtTokens = { repo, repoSlug, slug }
348
357
  const worktreeGuess = path.resolve(
349
- mainRepoPath,
358
+ dir,
350
359
  expandTokens(config.worktree.root, wtTokens),
351
360
  expandTokens(config.worktree.folderPattern, wtTokens),
352
361
  )
353
- const spec = resolveSpec(specArg, mainRepoPath, config, { searchDirs: [worktreeGuess] })
362
+ const spec = resolveSpec(specArg, dir, config, { searchDirs: [worktreeGuess] })
354
363
 
355
364
  if (!fs.existsSync(spec.worktreePath)) {
356
365
  process.stdout.write(
@@ -359,14 +368,19 @@ function specEnvIntegrate(dir, config, specArg) {
359
368
  return
360
369
  }
361
370
 
362
- const base = resolveBaseBranch(config, gitReader(mainRepoPath))
371
+ const base = resolveBaseBranch(config, gitReader(dir))
363
372
  const wtGit = gitReader(spec.worktreePath)
364
373
  const status = wtGit(['status', '--porcelain'])
365
374
  const dirty = status !== null && status.length > 0
366
375
  const ahead = wtGit(['rev-list', '--count', `${base}..HEAD`])
367
376
  const aheadOfBase = ahead !== null && Number(ahead) > 0
368
377
 
369
- const plan = planIntegrate(spec, config, { worktreeState: { dirty }, base, aheadOfBase, mainRepoPath })
378
+ const plan = planIntegrate(spec, config, {
379
+ worktreeState: { dirty },
380
+ base,
381
+ aheadOfBase,
382
+ mainRepoPath: dir,
383
+ })
370
384
 
371
385
  if (plan.blocked) {
372
386
  process.stdout.write(`spec-env integrate: blocked — ${plan.reason}.\n`)
@@ -583,6 +597,9 @@ async function specEnv(rest) {
583
597
  else positional.push(args[i])
584
598
  }
585
599
  dir = path.resolve(dir)
600
+ // Anchor on the primary checkout so every subcommand resolves {repo}, worktree
601
+ // paths, and the registry identically whether run from main or a worktree.
602
+ dir = resolvePrimaryCheckout(dir, gitReader(dir))
586
603
 
587
604
  const { config, present } = loadEnvConfig(dir)
588
605
  if (!present) {
package/src/env/config.js CHANGED
@@ -16,6 +16,8 @@
16
16
  * worktree: { root, folderPattern },
17
17
  * docker: { enabled, composeFile, projectNamePattern, portBase,
18
18
  * portsPerSpec, envFile, backupCommand },
19
+ * setup: [ "cmd", ... ], // bootstrap commands run in the worktree right
20
+ * // after `git worktree add` (e.g. install deps); empty = none
19
21
  * dev: [ { name, command, portVar, health?, frontPort? } ], // host dev
20
22
  * // servers started on the spec's port block (empty = none)
21
23
  * proxy: { enabled, host }, // bundled front-door proxy (spec-env connect)
@@ -43,6 +45,10 @@ const DEFAULT_CONFIG = Object.freeze({
43
45
  envFile: '.env',
44
46
  backupCommand: '',
45
47
  }),
48
+ // Bootstrap commands run in the worktree by `spec-env up`, right after
49
+ // `git worktree add` (before Docker/dev), on every provision. Array of shell
50
+ // strings (e.g. "pnpm install"); {slug}/{branch}/… expand. Default: none.
51
+ setup: Object.freeze([]),
46
52
  // Host dev servers started on the spec's port block by `spec-env dev up`.
47
53
  // Each: { name, command, portVar, health?, frontPort? }. Default: none.
48
54
  dev: Object.freeze([]),
@@ -70,6 +76,7 @@ function defaults() {
70
76
  return {
71
77
  worktree: { ...DEFAULT_CONFIG.worktree },
72
78
  docker: { ...DEFAULT_CONFIG.docker },
79
+ setup: [],
73
80
  dev: [],
74
81
  proxy: { ...DEFAULT_CONFIG.proxy },
75
82
  open: { ...DEFAULT_CONFIG.open },
@@ -120,6 +127,21 @@ function normalizeDev(parsed) {
120
127
  return out
121
128
  }
122
129
 
130
+ /**
131
+ * Normalise a parsed `setup` array into bootstrap commands: keep only trimmed,
132
+ * non-empty strings, drop everything else (lenient, like `normalizeDev`) so a
133
+ * stray entry can't crash provisioning.
134
+ */
135
+ function normalizeSetup(parsed) {
136
+ const out = []
137
+ for (const raw of parsed) {
138
+ if (typeof raw !== 'string') continue
139
+ const cmd = raw.trim()
140
+ if (cmd) out.push(cmd)
141
+ }
142
+ return out
143
+ }
144
+
123
145
  /**
124
146
  * Merge a parsed config over the defaults. Only known keys are copied (unknown
125
147
  * keys ignored for forward-compat). Nested objects are merged field-by-field.
@@ -142,6 +164,10 @@ function mergeConfig(base, parsed) {
142
164
  assign(base.docker, parsed.docker, 'backupCommand', 'string?')
143
165
  }
144
166
 
167
+ if (Array.isArray(parsed.setup)) {
168
+ base.setup = normalizeSetup(parsed.setup)
169
+ }
170
+
145
171
  if (Array.isArray(parsed.dev)) {
146
172
  base.dev = normalizeDev(parsed.dev)
147
173
  }
@@ -13,6 +13,7 @@
13
13
 
14
14
  const { portOffset } = require('./registry.js')
15
15
  const { renderEnvFile, expandOpenCommand } = require('./render.js')
16
+ const { expandTokens } = require('./resolve.js')
16
17
 
17
18
  /**
18
19
  * Plan a provisioning run.
@@ -23,7 +24,8 @@ const { renderEnvFile, expandOpenCommand } = require('./render.js')
23
24
  * existed in the registry (re-run → attach, don't clobber).
24
25
  * @param {object} config normalised env config.
25
26
  * @returns {object} { worktreePath, branch, projectName, slot, portOffset,
26
- * envContents, openCommand, commands, attached }
27
+ * envContents, openCommand, commands, setupCommands,
28
+ * attached }
27
29
  */
28
30
  function planUp(spec, alloc, config) {
29
31
  const { slot, attached } = alloc
@@ -41,13 +43,21 @@ function planUp(spec, alloc, config) {
41
43
  ? renderEnvFile({ projectName: spec.projectName, portOffset: offset })
42
44
  : null
43
45
 
44
- const openCommand = expandOpenCommand(config.open.command, {
46
+ const tokens = {
45
47
  worktreePath: spec.worktreePath,
46
48
  slug: spec.slug,
47
49
  branch: spec.branch,
48
50
  projectName: spec.projectName,
49
51
  portOffset: offset === null ? '' : String(offset),
50
- })
52
+ }
53
+
54
+ const openCommand = expandOpenCommand(config.open.command, tokens)
55
+
56
+ // Bootstrap commands run *in the worktree* after `git worktree add` (before
57
+ // Docker/dev), on every provision including re-attach — deps must exist for
58
+ // the worktree to be usable. Kept separate from `commands` (run from the
59
+ // primary checkout root); the CLI prints them under an "in the worktree" head.
60
+ const setupCommands = (config.setup || []).map((cmd) => expandTokens(cmd, tokens))
51
61
 
52
62
  const commands = []
53
63
  // Fresh branch → -b; attach an existing branch/slot → plain form (never clobber).
@@ -69,6 +79,7 @@ function planUp(spec, alloc, config) {
69
79
  envContents,
70
80
  openCommand,
71
81
  commands,
82
+ setupCommands,
72
83
  attached,
73
84
  }
74
85
  }
@@ -151,6 +151,20 @@ function resolveBaseBranch(config, git) {
151
151
  return 'main'
152
152
  }
153
153
 
154
+ /**
155
+ * Resolve `dir` to the primary checkout root — the parent of the shared git dir.
156
+ * From the primary checkout `git rev-parse --git-common-dir` is `.git` (relative),
157
+ * so the parent is `dir`; from a linked worktree it's the absolute `<main>/.git`,
158
+ * so the parent is `<main>`. Anchoring every `spec-env` command here means they
159
+ * resolve `{repo}` / worktree paths / the registry identically whether run from
160
+ * `main` or a worktree. `git(args)` returns trimmed stdout or `null` (not a repo)
161
+ * — injected for testability; a `null` degrades to `dir` (today's behaviour).
162
+ */
163
+ function resolvePrimaryCheckout(dir, git) {
164
+ const common = git(['rev-parse', '--git-common-dir'])
165
+ return common ? path.dirname(path.resolve(dir, common)) : dir
166
+ }
167
+
154
168
  /**
155
169
  * Resolve a spec argument to its identity + isolation coordinates.
156
170
  * Throws a clear Error when the spec folder can't be found.
@@ -193,6 +207,7 @@ function resolveSpec(specArg, dir, config, opts = {}) {
193
207
  module.exports = {
194
208
  resolveSpec,
195
209
  resolveBaseBranch,
210
+ resolvePrimaryCheckout,
196
211
  branchFor,
197
212
  splitPrefix,
198
213
  repoInfo,