@skitterbyte/skitterspec 22.3.2 → 22.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.
@@ -90,7 +90,17 @@ through to a documented conservative default; see the field notes below.
90
90
  "projectNamePattern": "{repoSlug}_{slug}", // → COMPOSE_PROJECT_NAME
91
91
  "portBase": 3000, // first port of slot 0's block
92
92
  "portsPerSpec": 10, // block width; slot n → portBase + n*portsPerSpec
93
- "envFile": ".env", // written into the worktree
93
+ // Written into the worktree, and passed to compose as `--env-file`. A
94
+ // Docker spec's worktree steps run in this order: seedFiles, this env file
95
+ // (COMPOSE_PROJECT_NAME and PORT_OFFSET appended only where missing, so a
96
+ // value `setup` or you put there is kept), `setup`, then
97
+ // `docker compose … up -d` — all from inside the worktree, so the stack
98
+ // reads this spec's values and not the primary checkout's.
99
+ // It must be a file of its OWN: if it is also in `seedFiles` as a symlink,
100
+ // the write is refused rather than send this spec's port block into the
101
+ // primary checkout's file. Name a different file here (".worktree.env")
102
+ // and have your compose file read the values from it.
103
+ "envFile": ".env",
94
104
  "backupCommand": "" // optional pre-teardown backup (e.g. pg_dump);
95
105
  // empty = no backup, volumes dropped directly.
96
106
  },
@@ -116,6 +126,8 @@ through to a documented conservative default; see the field notes below.
116
126
  // `local.settings.json`, `config/local.yml`.
117
127
  // Seeded files are gitignored, so they never make the worktree "dirty" and
118
128
  // never block teardown; they vanish with the worktree at `spec-env down`.
129
+ // On a Docker project, do not seed `docker.envFile` as a symlink — the env
130
+ // file step refuses to write through one (see `envFile` above).
119
131
  // [] (or absent) = seed nothing (current behaviour).
120
132
  "seedFiles": {
121
133
  "mode": "symlink",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skitterbyte/skitterspec",
3
- "version": "22.3.2",
3
+ "version": "22.4.0",
4
4
  "description": "Spec-driven development for Claude Code \u2014 a tracker-free filesystem workflow: lifecycle skills and per-spec isolation. For Linear sync, install @skitterbyte/skitterspec-linear instead.",
5
5
  "keywords": [
6
6
  "claude",
package/src/cli.js CHANGED
@@ -1097,21 +1097,20 @@ function specEnvUp(dir, config, specArg, flags = {}) {
1097
1097
  out.push('')
1098
1098
  out.push(' to provision, run:')
1099
1099
  for (const cmd of plan.commands) out.push(` ${cmd}`)
1100
- // Seed files first (setup may depend on them), then the setup commands —
1101
- // both run in the worktree, under one heading.
1102
- const worktreeSteps = [...plan.seedCommands, ...plan.setupCommands]
1100
+ // Everything after the fork runs in the worktree, under one heading, in the
1101
+ // order each step depends on the last: seeds (the env file may be one), the
1102
+ // env file, setup (which may derive values into it), and only then the stack.
1103
+ const worktreeSteps = [
1104
+ ...plan.seedCommands,
1105
+ ...(plan.envCommands || []),
1106
+ ...plan.setupCommands,
1107
+ ...(plan.dockerCommands || []),
1108
+ ]
1103
1109
  if (worktreeSteps.length) {
1104
1110
  out.push('')
1105
1111
  out.push(' then, in the worktree, run:')
1106
1112
  for (const cmd of worktreeSteps) out.push(` ${cmd}`)
1107
1113
  }
1108
- if (plan.envContents) {
1109
- out.push('')
1110
- out.push(` write ${config.docker.envFile} in the worktree:`)
1111
- for (const line of plan.envContents.replace(/\n$/, '').split('\n')) {
1112
- out.push(` ${line}`)
1113
- }
1114
- }
1115
1114
  process.stdout.write(out.join('\n') + '\n')
1116
1115
  }
1117
1116
 
@@ -4,8 +4,8 @@
4
4
  * Pure provisioning planner for `spec-env up`.
5
5
  *
6
6
  * Given a resolved spec and its allocated slot, `planUp` returns the exact
7
- * side-effecting commands the `/spec-env` skill runs (`git worktree add`,
8
- * `docker compose up`) and the rendered `.env` contents — but performs no side
7
+ * side-effecting commands the `/spec-env` skill runs (`git worktree add`, the
8
+ * env file, `docker compose up`) and the rendered `.env` contents — but performs no side
9
9
  * effects itself. The caller (the CLI) reads/allocates the
10
10
  * registry and passes the slot; this stays deterministic and unit-testable with
11
11
  * no live git/docker.
@@ -73,6 +73,50 @@ function seedCommandFor(file, mode) {
73
73
  }
74
74
 
75
75
 
76
+ // Single-quote a value for POSIX sh: the only character that needs escaping
77
+ // inside single quotes is the single quote itself.
78
+ function shq(value) {
79
+ return `'${String(value).replace(/'/g, `'\\''`)}'`
80
+ }
81
+
82
+ /**
83
+ * Build one idempotent POSIX-sh command that writes the spec's env file in the
84
+ * current worktree (the cwd when the skill runs it).
85
+ *
86
+ * It was prose once — "write <envFile> in the worktree:" — which left the write
87
+ * to whoever read the plan, and a secrets guard that blocks any tool call naming
88
+ * `.env` refused it. As a command it is one more step the plan runs.
89
+ *
90
+ * Idempotent and additive: it `touch`es the file and appends each key only when
91
+ * no line already sets it, so a re-run changes nothing and a file `setup` has
92
+ * already written to keeps every value it holds — including a deliberate
93
+ * `PORT_OFFSET` of its own.
94
+ *
95
+ * A SYMLINK IS REFUSED, loudly. Seeding links gitignored files from the primary
96
+ * checkout, so a seeded env file is the primary checkout's own; appending
97
+ * through it would write this spec's port block into the main stack's
98
+ * configuration. It runs after the seeds precisely so that collision is caught
99
+ * rather than hidden. The fix is a `docker.envFile` naming a file of its own.
100
+ */
101
+ function envFileCommandFor(envFile, projectName, offset) {
102
+ const f = shq(envFile)
103
+ const ensure = (key, value) =>
104
+ `{ grep -q '^${key}=' ${f} || echo ${shq(`${key}=${value}`)} >> ${f}; }`
105
+ return (
106
+ `if [ -L ${f} ]; then echo ${shq(
107
+ `env file ${envFile} is a symlink — docker.envFile must name a file of its ` +
108
+ "own, not a seeded one; refusing to write this spec's port block through it",
109
+ )} >&2; exit 1; fi; ` +
110
+ `touch ${f} && ` +
111
+ // A file without a trailing newline would glue the first appended key onto
112
+ // its last line.
113
+ `{ [ ! -s ${f} ] || [ -z "$(tail -c 1 ${f})" ] || echo >> ${f}; } && ` +
114
+ `${ensure('COMPOSE_PROJECT_NAME', projectName)} && ` +
115
+ `${ensure('PORT_OFFSET', offset)} && ` +
116
+ `echo ${shq(`env file ${envFile}: ready`)}`
117
+ )
118
+ }
119
+
76
120
  // Cap a path list in a message: enough to recognise, not a wall of text.
77
121
  function listPaths(paths) {
78
122
  const shown = paths.slice(0, 5).join(', ')
@@ -294,8 +338,10 @@ function resolveStack(spec, config, { docs = false, composeFilePresent } = {}) {
294
338
  * @param {object} config normalised env config.
295
339
  * @param {object} opts { docs } — documents mode (see below). Optional.
296
340
  * @returns {object} { worktreePath, branch, projectName, slot, portOffset,
297
- * envContents, commands, seedCommands,
298
- * setupCommands, docs, attached }
341
+ * envContents, commands, seedCommands, envCommands,
342
+ * setupCommands, dockerCommands, docs, attached }
343
+ *
344
+ * The worktree steps run in that order: seeds, env file, setup, compose.
299
345
  */
300
346
  function planUp(spec, alloc, config, ctx, opts = {}) {
301
347
  const { slot, attached } = alloc
@@ -367,12 +413,24 @@ function planUp(spec, alloc, config, ctx, opts = {}) {
367
413
  ? `git worktree add ${spec.worktreePath} ${spec.branch}`
368
414
  : `git worktree add ${spec.worktreePath} -b ${spec.branch}${forkPoint}`,
369
415
  )
370
- if (wantsDocker) {
371
- commands.push(
372
- `docker compose${composeFileArg(config, ctx ? ctx.composeFilePresent : undefined)}` +
373
- ` --project-name ${spec.projectName} up -d`,
374
- )
375
- }
416
+ // The stack comes up LAST, in the worktree: after the seeds, after the env
417
+ // file, after `setup` — which may derive values into that file. It once ran
418
+ // from the primary checkout before the worktree existed, so compose read the
419
+ // primary checkout's env file, fell back to its defaults and collided with the
420
+ // main stack. `--env-file` is always passed: with the default `.env` it is what
421
+ // compose would have found anyway, and with any other name it is the only way
422
+ // compose finds it.
423
+ const envFile = (config.docker && config.docker.envFile) || '.env'
424
+ const envCommands = wantsDocker
425
+ ? [`${guard}; ${envFileCommandFor(envFile, spec.projectName, offset)}`]
426
+ : []
427
+ const dockerCommands = wantsDocker
428
+ ? [
429
+ `${guard}; docker compose` +
430
+ composeFileArg(config, ctx ? ctx.composeFilePresent : undefined) +
431
+ ` --env-file ${shq(envFile)} --project-name ${spec.projectName} up -d`,
432
+ ]
433
+ : []
376
434
 
377
435
  // The tree gate. A worktree forks from the base branch's tree, so an
378
436
  // uncommitted spec would produce a branch missing the spec it is for — this is
@@ -398,11 +456,13 @@ function planUp(spec, alloc, config, ctx, opts = {}) {
398
456
  envContents,
399
457
  commands: gate.blocked ? [] : [...gate.commands, ...commands],
400
458
  seedCommands: gate.blocked ? [] : seedCommands,
459
+ envCommands: gate.blocked ? [] : envCommands,
401
460
  // Documents mode drops these, and keeps the seed commands above. The two are
402
461
  // not the same kind of thing: seeding links the gitignored files a worktree
403
462
  // has none of — cheap, and a spec author may well want `.env` present —
404
463
  // while `setup` is the expensive half that exists to make the tree run.
405
464
  setupCommands: gate.blocked || docs ? [] : setupCommands,
465
+ dockerCommands: gate.blocked ? [] : dockerCommands,
406
466
  // Reported so a caller reading `--json` can tell a documents worktree from a
407
467
  // full one POSITIVELY, rather than inferring it from what is missing. An
408
468
  // empty `setupCommands` also describes a project that configured no setup
@@ -481,6 +541,7 @@ module.exports = {
481
541
  planCheckoutUp,
482
542
  planSpecCommit,
483
543
  seedCommandFor,
544
+ envFileCommandFor,
484
545
  worktreeCd,
485
546
  resolveStack,
486
547
  composeFileArg,
@@ -94,6 +94,10 @@ function planDown(spec, config, flags, ctx) {
94
94
  }
95
95
 
96
96
  // --- docker compose down (drop volumes unless kept) ---
97
+ // Run from the primary checkout with no `--env-file`, unlike `up`, and on
98
+ // purpose: compose finds the stack's containers, networks and volumes by the
99
+ // project label `--project-name` gives it, so it needs nothing from the env
100
+ // file — which may already be gone with a half-removed worktree.
97
101
  if (wantsDocker) {
98
102
  const base =
99
103
  `docker compose${composeFileArg(config, ctx ? ctx.composeFilePresent : undefined)}` +