@junheep/gwt 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +19 -6
  2. package/bin/gwt.mjs +180 -27
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -29,9 +29,10 @@ gwt shell install zsh
29
29
  ```
30
30
 
31
31
  The installer shows the line it will add to `~/.zshrc` and asks for
32
- confirmation. The integration also provides Zsh completion for commands,
33
- options, worktrees, and Git refs. It only changes directories; it does not load
34
- environment variables or run project hooks.
32
+ confirmation. The integration also provides Zsh completion and automatically
33
+ loads assigned ports and configured environment variables when Zsh enters a
34
+ managed worktree. Previous values are restored when Zsh leaves it. Normal
35
+ environment synchronization produces no output.
35
36
 
36
37
  ## Coding agents
37
38
 
@@ -90,6 +91,9 @@ Repositories without a remote use their canonical path.
90
91
  "WEB_PORT",
91
92
  "SERVER_PORT"
92
93
  ],
94
+ "env": {
95
+ "NEXT_PUBLIC_API_ENDPOINT": "http://127.0.0.1:${SERVER_PORT}"
96
+ },
93
97
  "postCreate": "hooks/worktree-setup",
94
98
  "preRemove": "hooks/worktree-cleanup"
95
99
  }
@@ -121,6 +125,9 @@ The project file contains the configuration fields directly:
121
125
  "WEB_PORT",
122
126
  "SERVER_PORT"
123
127
  ],
128
+ "env": {
129
+ "NEXT_PUBLIC_API_ENDPOINT": "http://127.0.0.1:${SERVER_PORT}"
130
+ },
124
131
  "postCreate": "./scripts/worktree-setup",
125
132
  "preRemove": "./scripts/worktree-cleanup"
126
133
  }
@@ -145,6 +152,10 @@ current commit as their base and run no setup actions.
145
152
  overwriting an existing destination.
146
153
  - `ports`: Environment variable names assigned stable ports in the range
147
154
  20000–39999.
155
+ - `env`: Environment variables loaded alongside assigned ports. Values are
156
+ literal strings with optional `${PORT_NAME}` references to names declared in
157
+ `ports`. Shell expressions and references to arbitrary process variables are
158
+ not evaluated.
148
159
  - `postCreate`: Executable run after files and ports are prepared.
149
160
  - `preRemove`: Executable run before removal.
150
161
 
@@ -170,6 +181,7 @@ GWT_PATH
170
181
  GWT_PRIMARY_PATH
171
182
  GWT_BRANCH
172
183
  <each name declared in ports>
184
+ <each name declared in env>
173
185
  ```
174
186
 
175
187
  Example `postCreate` hook:
@@ -187,9 +199,10 @@ Hook paths in user config are resolved relative to the directory containing
187
199
  worktree. Both run with the target worktree as their working directory, and
188
200
  their standard output and errors are streamed directly to the terminal.
189
201
 
190
- Hooks in user config are trusted because the user added them directly. Hooks
191
- from a committed `.gwt.json` require explicit trust because they execute
192
- repository code:
202
+ User configuration is trusted because the user added it directly. Ports and
203
+ environment variables from a committed `.gwt.json` require explicit trust
204
+ because they automatically change the shell; repository hooks require the same
205
+ approval because they execute code:
193
206
 
194
207
  ```sh
195
208
  gwt trust
package/bin/gwt.mjs CHANGED
@@ -28,7 +28,8 @@ const PORT_MIN = 20_000
28
28
  const PORT_MAX = 39_999
29
29
  const PICKER_ESCAPE_CODE_TIMEOUT_MS = 50
30
30
  const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
31
- const DEFAULT_CONFIG = { copyFiles: [], ports: [] }
31
+ const SHELL_ENV_STATE = "GWT_SHELL_ENV_STATE"
32
+ const DEFAULT_CONFIG = { copyFiles: [], ports: [], env: {} }
32
33
  const SKILL_DIRECTORIES = { claude: ".claude", codex: ".agents" }
33
34
  const SKILL_USAGE = `Usage: gwt skill install <${Object.keys(SKILL_DIRECTORIES).join("|")}> [--project] [--dry-run] [--yes]`
34
35
 
@@ -127,7 +128,7 @@ function validateRelativePath(value, field) {
127
128
 
128
129
  function validateConfig(parsed, label) {
129
130
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new CliError(`${label} must contain an object`)
130
- const allowed = new Set(["base", "worktreeDirectory", "copyFiles", "ports", "postCreate", "preRemove"])
131
+ const allowed = new Set(["base", "worktreeDirectory", "copyFiles", "ports", "env", "postCreate", "preRemove"])
131
132
  for (const key of Object.keys(parsed)) {
132
133
  if (!allowed.has(key)) throw new CliError(`${label} contains an unknown field: ${key}`)
133
134
  }
@@ -150,11 +151,33 @@ function validateConfig(parsed, label) {
150
151
  if (!Array.isArray(parsed.ports ?? [])) throw new CliError("ports must be an array")
151
152
  const ports = (parsed.ports ?? []).map((name, index) => {
152
153
  if (typeof name !== "string" || !ENV_NAME.test(name)) throw new CliError(`ports[${index}] is not a valid environment variable name`)
154
+ if (name.startsWith("GWT_")) throw new CliError(`ports[${index}] cannot use the reserved GWT_ prefix`)
153
155
  return name
154
156
  })
155
157
  if (new Set(ports).size !== ports.length) throw new CliError("ports cannot contain duplicates")
156
158
  if (ports.length > 100) throw new CliError("ports cannot contain more than 100 entries")
157
159
 
160
+ if (!parsed.env || typeof parsed.env !== "object" || Array.isArray(parsed.env)) {
161
+ if (parsed.env !== undefined) throw new CliError("env must be an object")
162
+ }
163
+ const envEntries = Object.entries(parsed.env ?? {})
164
+ if (envEntries.length > 100) throw new CliError("env cannot contain more than 100 entries")
165
+ const env = Object.fromEntries(envEntries.map(([name, value]) => {
166
+ if (!ENV_NAME.test(name)) throw new CliError(`env.${name} is not a valid environment variable name`)
167
+ if (name.startsWith("GWT_")) throw new CliError(`env.${name} cannot use the reserved GWT_ prefix`)
168
+ if (ports.includes(name)) throw new CliError(`env.${name} conflicts with a configured port`)
169
+ if (typeof value !== "string") throw new CliError(`env.${name} must be a string`)
170
+
171
+ const remainder = value.replace(/\$\{([^}]*)\}/g, (_, reference) => {
172
+ if (!ENV_NAME.test(reference) || !ports.includes(reference)) {
173
+ throw new CliError(`env.${name} references unknown port ${reference || "(empty)"}`)
174
+ }
175
+ return ""
176
+ })
177
+ if (remainder.includes("${")) throw new CliError(`env.${name} contains an invalid port reference`)
178
+ return [name, value]
179
+ }))
180
+
158
181
  for (const hook of ["postCreate", "preRemove"]) {
159
182
  if (parsed[hook] !== undefined) validateRelativePath(parsed[hook], hook)
160
183
  }
@@ -163,11 +186,24 @@ function validateConfig(parsed, label) {
163
186
  ...parsed,
164
187
  copyFiles,
165
188
  ports,
189
+ env,
166
190
  }
167
191
  if (worktreeDirectory !== undefined) config.worktreeDirectory = worktreeDirectory
168
192
  return config
169
193
  }
170
194
 
195
+ function resolveConfiguredEnv(config, ports) {
196
+ return Object.fromEntries(Object.entries(config.env).map(([name, template]) => [
197
+ name,
198
+ template.replace(/\$\{([^}]*)\}/g, (_, reference) => {
199
+ if (!Object.hasOwn(ports, reference)) {
200
+ throw new CliError(`Cannot resolve env.${name}: this worktree has no assigned ${reference}`)
201
+ }
202
+ return String(ports[reference])
203
+ }),
204
+ ]))
205
+ }
206
+
171
207
  function configHome() {
172
208
  return process.env.XDG_CONFIG_HOME || join(homedir(), ".config")
173
209
  }
@@ -447,7 +483,7 @@ function hookPaths(configDocument, worktreePath) {
447
483
 
448
484
  function trustFingerprint(repository, configDocument, worktreePath) {
449
485
  const hooks = hookPaths(configDocument, worktreePath)
450
- if (hooks.length === 0) return null
486
+ if (hooks.length === 0 && configDocument.value.ports.length === 0 && Object.keys(configDocument.value.env).length === 0) return null
451
487
  const hash = createHash("sha256")
452
488
  hash.update(repository.primaryPath)
453
489
  hash.update("\0")
@@ -517,20 +553,24 @@ async function ensureTrusted(repository, configDocument, worktreePath) {
517
553
  if (!fingerprint || isTrusted(repository, fingerprint)) return
518
554
 
519
555
  const hooks = hookPaths(configDocument, worktreePath)
520
- console.error("This repository wants to run:")
556
+ console.error("This repository wants to configure your development environment:")
521
557
  for (const hook of hooks) console.error(` ${hook.name}: ${hook.configuredPath}`)
558
+ for (const name of configDocument.value.ports) console.error(` port: ${name}`)
559
+ for (const name of Object.keys(configDocument.value.env)) console.error(` env: ${name}`)
522
560
  const allowed = await ask("Allow and remember? [y/N] ")
523
- if (!allowed) throw new CliError("Project hooks are not trusted. Run 'gwt trust' to approve them")
561
+ if (!allowed) throw new CliError("Project configuration is not trusted. Run 'gwt trust' to approve it")
524
562
  saveTrust(repository, fingerprint)
525
563
  }
526
564
 
527
- function hookContext(repository, worktree, metadata) {
565
+ function hookContext(repository, config, worktree, metadata) {
566
+ const ports = metadata?.ports ?? {}
528
567
  return {
529
568
  id: metadata?.id ?? "",
530
569
  path: canonical(worktree.path),
531
570
  primaryPath: repository.primaryPath,
532
571
  branch: worktree.branch ?? "",
533
- ports: metadata?.ports ?? {},
572
+ ports,
573
+ environment: resolveConfiguredEnv(config, ports),
534
574
  }
535
575
  }
536
576
 
@@ -538,7 +578,7 @@ function runHook(name, repository, configDocument, worktree, metadata) {
538
578
  const configuredPath = configDocument.value[name]
539
579
  if (!configuredPath) return
540
580
  const hook = hookPaths(configDocument, canonical(worktree.path)).find((item) => item.name === name)
541
- const context = hookContext(repository, worktree, metadata)
581
+ const context = hookContext(repository, configDocument.value, worktree, metadata)
542
582
  const env = {
543
583
  ...process.env,
544
584
  GWT_ID: context.id,
@@ -546,6 +586,7 @@ function runHook(name, repository, configDocument, worktree, metadata) {
546
586
  GWT_PRIMARY_PATH: context.primaryPath,
547
587
  GWT_BRANCH: context.branch,
548
588
  ...Object.fromEntries(Object.entries(context.ports).map(([key, value]) => [key, String(value)])),
589
+ ...context.environment,
549
590
  }
550
591
  console.log(`Running ${name}...`)
551
592
  const result = run(hook.path, [], {
@@ -1062,21 +1103,21 @@ function commandTrust(args) {
1062
1103
  const configDocument = loadConfig(repository)
1063
1104
  if (!configDocument.requiresTrust) {
1064
1105
  console.log(configDocument.source === "user"
1065
- ? "User config hooks are trusted automatically"
1106
+ ? "User configuration is trusted automatically"
1066
1107
  : "This repository has no project config to approve")
1067
1108
  return
1068
1109
  }
1069
1110
  const fingerprint = trustFingerprint(repository, configDocument, canonical(current.path))
1070
1111
  if (!fingerprint) {
1071
- console.log("This repository has no project hooks to approve")
1112
+ console.log("This repository has no project configuration that requires approval")
1072
1113
  return
1073
1114
  }
1074
1115
  saveTrust(repository, fingerprint)
1075
- console.log(`Trusted project hooks for ${repository.primaryPath}`)
1116
+ console.log(`Trusted project configuration for ${repository.primaryPath}`)
1076
1117
  }
1077
1118
 
1078
1119
  function configScaffold() {
1079
- return { copyFiles: [], ports: [] }
1120
+ return { copyFiles: [], ports: [], env: {} }
1080
1121
  }
1081
1122
 
1082
1123
  function commandConfigCreate(args) {
@@ -1141,6 +1182,94 @@ function commandConfig(args) {
1141
1182
  throw new CliError("Usage: gwt config <create [--project]|show>")
1142
1183
  }
1143
1184
 
1185
+ function configuredShellEnvironment() {
1186
+ const insideRepository = git(["rev-parse", "--is-inside-work-tree"], process.cwd(), { allowFailure: true })
1187
+ if (insideRepository.status !== 0) return {}
1188
+
1189
+ const repository = discoverRepository()
1190
+ const worktree = currentWorktree(repository)
1191
+ if (!worktree || resolve(worktree.path) === resolve(repository.primaryPath)) return {}
1192
+
1193
+ const metadata = metadataForWorktree(repository, worktree)
1194
+ if (!metadata) return {}
1195
+
1196
+ const configDocument = loadConfig(repository)
1197
+ if (configDocument.requiresTrust) {
1198
+ const fingerprint = trustFingerprint(repository, configDocument, canonical(worktree.path))
1199
+ if (!isTrusted(repository, fingerprint)) return {}
1200
+ }
1201
+
1202
+ const ports = Object.fromEntries(configDocument.value.ports.map((name) => {
1203
+ if (!Object.hasOwn(metadata.ports ?? {}, name)) {
1204
+ throw new CliError(`This worktree has no assigned ${name}; recreate it after changing ports`)
1205
+ }
1206
+ return [name, String(metadata.ports[name])]
1207
+ }))
1208
+ return { ...ports, ...resolveConfiguredEnv(configDocument.value, metadata.ports ?? {}) }
1209
+ }
1210
+
1211
+ function readShellEnvironmentState() {
1212
+ const encoded = process.env[SHELL_ENV_STATE]
1213
+ if (!encoded) return { originals: {} }
1214
+
1215
+ try {
1216
+ const parsed = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"))
1217
+ if (!parsed || typeof parsed.originals !== "object" || Array.isArray(parsed.originals)) throw new Error()
1218
+ const originals = Object.fromEntries(Object.entries(parsed.originals).map(([name, original]) => {
1219
+ if (!ENV_NAME.test(name) || name.startsWith("GWT_")) throw new Error()
1220
+ if (!original || typeof original !== "object" || typeof original.present !== "boolean") throw new Error()
1221
+ if (original.present && typeof original.value !== "string") throw new Error()
1222
+ return [name, original.present ? { present: true, value: original.value } : { present: false }]
1223
+ }))
1224
+ return { originals }
1225
+ } catch {
1226
+ return { originals: {} }
1227
+ }
1228
+ }
1229
+
1230
+ function quoteZsh(value) {
1231
+ return `'${String(value).replaceAll("'", `'\\''`)}'`
1232
+ }
1233
+
1234
+ function shellEnvironmentCommands(environment) {
1235
+ const previous = readShellEnvironmentState()
1236
+ const names = new Set([...Object.keys(previous.originals), ...Object.keys(environment)])
1237
+ const originals = {}
1238
+ const commands = []
1239
+
1240
+ for (const name of names) {
1241
+ const original = previous.originals[name] ?? (Object.hasOwn(process.env, name)
1242
+ ? { present: true, value: process.env[name] }
1243
+ : { present: false })
1244
+
1245
+ if (Object.hasOwn(environment, name)) {
1246
+ originals[name] = original
1247
+ commands.push(`export ${name}=${quoteZsh(environment[name])}`)
1248
+ } else if (original.present) {
1249
+ commands.push(`export ${name}=${quoteZsh(original.value)}`)
1250
+ } else {
1251
+ commands.push(`unset ${name}`)
1252
+ }
1253
+ }
1254
+
1255
+ if (Object.keys(originals).length === 0) {
1256
+ commands.push(`unset ${SHELL_ENV_STATE}`)
1257
+ } else {
1258
+ const state = Buffer.from(JSON.stringify({ originals })).toString("base64url")
1259
+ commands.push(`export ${SHELL_ENV_STATE}=${quoteZsh(state)}`)
1260
+ }
1261
+ return commands.join("\n")
1262
+ }
1263
+
1264
+ function commandShellEnvironment(args) {
1265
+ if (args.length !== 1 || args[0] !== "zsh") throw new CliError("Invalid shell environment request")
1266
+ let environment = {}
1267
+ try {
1268
+ environment = configuredShellEnvironment()
1269
+ } catch {}
1270
+ console.log(shellEnvironmentCommands(environment))
1271
+ }
1272
+
1144
1273
  function zshIntegration() {
1145
1274
  return `# gwt shell integration for zsh
1146
1275
  if command -v gwt >/dev/null 2>&1; then
@@ -1152,9 +1281,18 @@ if command -v gwt >/dev/null 2>&1; then
1152
1281
  builtin cd -- "$(<"$cd_file")" || exit_code=$?
1153
1282
  fi
1154
1283
  rm -f -- "$cd_file"
1284
+ if [[ $exit_code -eq 0 ]]; then
1285
+ _gwt_sync_env
1286
+ fi
1155
1287
  return $exit_code
1156
1288
  }
1157
1289
 
1290
+ _gwt_sync_env() {
1291
+ local commands
1292
+ commands="$(command gwt __shell_env zsh 2>/dev/null)" || return 0
1293
+ [[ -n "$commands" ]] && eval "$commands"
1294
+ }
1295
+
1158
1296
  _gwt_worktrees() {
1159
1297
  local -a targets
1160
1298
  targets=("\${(@f)$(command gwt __complete worktrees 2>/dev/null)}")
@@ -1252,6 +1390,12 @@ if command -v gwt >/dev/null 2>&1; then
1252
1390
  if (( $+functions[compdef] )); then
1253
1391
  compdef _gwt gwt
1254
1392
  fi
1393
+
1394
+ typeset -ga chpwd_functions
1395
+ if (( ! \${chpwd_functions[(I)_gwt_sync_env]} )); then
1396
+ chpwd_functions+=(_gwt_sync_env)
1397
+ fi
1398
+ _gwt_sync_env
1255
1399
  fi`
1256
1400
  }
1257
1401
 
@@ -1280,13 +1424,16 @@ stay accurate across versions.
1280
1424
 
1281
1425
  ## What the help does not make obvious
1282
1426
 
1283
- - Hooks declared by a committed \`.gwt.json\` do not run until the repository is
1284
- approved with \`gwt trust\`. Approval is invalidated whenever the config or a
1285
- hook changes, so a repository that worked before can start asking again.
1427
+ - Ports, environment variables, and hooks declared by a committed \`.gwt.json\`
1428
+ are not applied until the repository is approved with \`gwt trust\`. Approval
1429
+ is invalidated whenever the config or a hook changes, so a repository that
1430
+ worked before can start asking again.
1286
1431
  - A failed setup keeps the worktree and records the failure. Retry it with
1287
1432
  \`gwt setup <id>\` rather than removing and recreating the worktree.
1288
- - Ports are assigned per worktree. Read them from \`gwt info\` instead of assuming
1289
- a project default; two worktrees of the same project never share a port.
1433
+ - Ports are assigned per worktree. With shell integration installed, assigned
1434
+ ports and configured environment variables load automatically. Read ports
1435
+ from \`gwt info\` instead of assuming a project default; two worktrees of the
1436
+ same project never share a port.
1290
1437
  - \`gwt switch\` changes the shell's directory only when the shell integration is
1291
1438
  installed. Otherwise it just prints the path.
1292
1439
  - \`gwt switch\` with no target opens an interactive picker, so always pass an
@@ -1387,11 +1534,12 @@ function commandComplete(args) {
1387
1534
  const repository = discoverRepository()
1388
1535
  const values = ["primary"]
1389
1536
  for (const worktree of repository.worktrees) {
1537
+ if (resolve(worktree.path) === resolve(repository.primaryPath)) continue
1390
1538
  const metadata = metadataForWorktree(repository, worktree)
1391
- if (metadata?.id) values.push(metadata.id)
1392
- if (worktree.branch) values.push(worktree.branch)
1539
+ const selector = worktree.branch ?? metadata?.id
1540
+ if (selector) values.push(selector)
1393
1541
  }
1394
- console.log([...new Set(values)].join("\n"))
1542
+ console.log(values.join("\n"))
1395
1543
  return
1396
1544
  }
1397
1545
 
@@ -1430,7 +1578,7 @@ Commands:
1430
1578
  switch Switch the current shell to a worktree
1431
1579
  info Show worktree details and assigned ports
1432
1580
  remove Safely remove a worktree and optionally its branch
1433
- trust Approve or revoke repository project hooks
1581
+ trust Approve or revoke repository project configuration
1434
1582
  config Create or inspect configuration
1435
1583
  shell Install shell integration
1436
1584
  skill Install the gwt skill for coding agents
@@ -1575,7 +1723,7 @@ Examples:
1575
1723
  gwt remove
1576
1724
  gwt remove feature/auth --keep-branch
1577
1725
  gwt remove a1b2c3d4 --discard --yes`,
1578
- trust: `Approve or revoke hooks declared by the repository's .gwt.json.
1726
+ trust: `Approve or revoke active configuration declared by the repository's .gwt.json.
1579
1727
 
1580
1728
  Usage:
1581
1729
  gwt trust [--revoke]
@@ -1584,9 +1732,10 @@ Options:
1584
1732
  --revoke Remove the stored approval for this repository.
1585
1733
  -h, --help Show help for this command.
1586
1734
 
1587
- Approval is tied to the configuration and hook contents, so changing either
1588
- requires approval again. Hooks declared in user configuration are trusted
1589
- automatically.
1735
+ Approval is required before repository-defined ports or environment variables
1736
+ are applied, or repository hooks run. It is tied to the configuration and hook
1737
+ contents, so changing either requires approval again. User configuration is
1738
+ trusted automatically.
1590
1739
 
1591
1740
  Examples:
1592
1741
  gwt trust
@@ -1653,7 +1802,8 @@ Options:
1653
1802
  -h, --help Show help for this command.
1654
1803
 
1655
1804
  The integration lets gwt change the current shell's directory after new,
1656
- switch, and removal of the current worktree. It also installs completion.
1805
+ switch, and removal of the current worktree. It also loads the worktree's
1806
+ assigned ports and configured environment, and installs completion.
1657
1807
 
1658
1808
  Example:
1659
1809
  gwt shell install zsh`,
@@ -1668,7 +1818,9 @@ Options:
1668
1818
  -h, --help Show help for this command.
1669
1819
 
1670
1820
  The command adds one initialization line to ~/.zshrc, or to $ZDOTDIR/.zshrc
1671
- when ZDOTDIR is set. Restart Zsh or source the file after installation.
1821
+ when ZDOTDIR is set. Restart Zsh or source the file after installation. The
1822
+ integration updates the environment when Zsh starts or changes directory and
1823
+ restores previous values after leaving a managed worktree.
1672
1824
 
1673
1825
  Examples:
1674
1826
  gwt shell install zsh
@@ -1745,6 +1897,7 @@ async function main() {
1745
1897
  if (command === "shell") return commandShell(args)
1746
1898
  if (command === "skill") return commandSkill(args)
1747
1899
  if (command === "__complete") return commandComplete(args)
1900
+ if (command === "__shell_env") return commandShellEnvironment(args)
1748
1901
  throw new CliError(`Unknown command: ${command}`)
1749
1902
  }
1750
1903
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junheep/gwt",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Lightweight native Git worktree workflows",
5
5
  "license": "MIT",
6
6
  "author": "Junhee Park",