@next-friday/create-app 6.0.0 → 6.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.
package/dist/main.mjs CHANGED
@@ -81,12 +81,15 @@ async function writeEnvironmentFile(directory) {
81
81
  */
82
82
  function initGit(directory) {
83
83
  if (existsSync(path.join(directory, ".git"))) return "existing";
84
+ const environment = projectGitEnvironment();
84
85
  if (spawnSync("git", ["rev-parse", "--is-inside-work-tree"], {
85
86
  cwd: directory,
87
+ env: environment,
86
88
  stdio: "ignore"
87
89
  }).status === 0) return "parent";
88
90
  return spawnSync("git", ["init", "--initial-branch=main"], {
89
91
  cwd: directory,
92
+ env: environment,
90
93
  stdio: "ignore"
91
94
  }).status === 0 ? "initialized" : "failed";
92
95
  }
@@ -99,9 +102,22 @@ function didInstallDependencies(directory) {
99
102
  return spawnSync("pnpm install", {
100
103
  cwd: directory,
101
104
  stdio: "inherit",
102
- shell: true
105
+ shell: true,
106
+ env: projectGitEnvironment()
103
107
  }).status === 0;
104
108
  }
109
+ /**
110
+ * The environment without the repository variables that `git rev-parse --local-env-vars` lists.
111
+ * An inherited `GIT_DIR`, as Git sets for its hooks, would point Git, and the install's Git hook
112
+ * setup, at another repository. `git -c` options stay, as Git keeps them for another repository.
113
+ * @returns Environment for commands that run Git in the project directory.
114
+ */
115
+ function projectGitEnvironment() {
116
+ const localVariables = new Set(spawnSync("git", ["rev-parse", "--local-env-vars"], { encoding: "utf8" }).stdout?.split("\n"));
117
+ localVariables.delete("GIT_CONFIG_PARAMETERS");
118
+ localVariables.delete("GIT_CONFIG_COUNT");
119
+ return Object.fromEntries(Object.entries(process.env).filter(([name]) => !localVariables.has(name)));
120
+ }
105
121
  // src/project-name.ts
106
122
  const packageNamePattern = /^[a-z0-9][a-z0-9._-]*$/;
107
123
  const maxPackageNameLength = 214;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@next-friday/create-app",
3
- "version": "6.0.0",
3
+ "version": "6.0.1",
4
4
  "description": "Create a Next Friday project from a template.",
5
5
  "keywords": [
6
6
  "create",
@@ -1,10 +1,48 @@
1
- #!/bin/bash
2
- # Production deploys require a named release authorization
3
- cmd=$(jq -r '.tool_input.command // .command' < /dev/stdin)
4
- if [[ "$cmd" == *"deploy"* && "$cmd" == *"production"* ]]; then
5
- if [ -z "$RELEASE_APPROVAL" ]; then
6
- echo "Production deploys need a release authorization." >&2
7
- exit 2 # exit 2 blocks the action; the message goes to Claude
8
- fi
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+
4
+ allow() {
5
+ printf '%s\n' '{"permission":"allow"}'
6
+ exit 0
7
+ }
8
+
9
+ block() {
10
+ jq -n --arg message "$1" '{permission:"deny",user_message:$message,agent_message:$message}'
11
+ printf 'agent-production-gate: %s\n' "$1" >&2
12
+ exit 2
13
+ }
14
+
15
+ trap 'block ".agents/hooks/agent-production-gate.sh failed. Stop and report this error to the user; agents may not edit the gate."' ERR
16
+ command -v jq > /dev/null || block "jq is required to read the hook input. Install it: https://jqlang.org/download/"
17
+ command=$(jq -r '.command // .tool_input.command // empty')
18
+ [[ -n $command && -z ${RELEASE_APPROVAL:-} ]] || allow
19
+
20
+ release_gate="A release manager runs prod deploys, commands against prod, and release tag pushes. Do not run this; report what is ready to promote, such as the commit SHA. If a gated command appears only inside a message, write the text to a file and pass it with git commit -F <file> or gh pr create --body-file <file>."
21
+
22
+ # The gate reads the command line as text, joining continued lines and dropping quotes,
23
+ # backslashes, and fd redirections, so wrappers such as bash -c, eval, or npx and split words such
24
+ # as pr''od do not hide the common forms. A dispatch input or Railway environment the gate cannot
25
+ # read (a variable, a command substitution, a file, or stdin) counts as prod. It is a convenience,
26
+ # not a security boundary: the prod environment's required reviewers and Railway credentials are.
27
+ text=${command//'\\'/}
28
+ text=${text//$'\\\n'/}
29
+ text=$(sed -E 's/([0-9]*[<>]&[0-9-]*|&>>?)//g' <<< "${text//[\"\'\\]/}")
30
+ simple=$'[^;&|\n]*'
31
+ prod="(prod([^[:alnum:]_-]|$)|[\$\`@])"
32
+ dispatch="(workflow[[:space:]]+run|/dispatches)"
33
+ environment_field="environment[]]*[=:][[:space:]]*$prod"
34
+ unreadable_inputs="--(json|input)([[:space:]=]|$)"
35
+ railway="(^|[^[:alnum:]_.-])(railway|@railway/cli(@[^[:space:]]*)?)([[:space:]]$simple)?"
36
+ git_push="(^|[^[:alnum:]_.-])git([[:space:]]+(-[Cc][[:space:]]+[^[:space:]]+|--(git-dir|work-tree|namespace|config-env)[[:space:]]+[^[:space:]]+|--?[^[:space:]]+))*[[:space:]]+push([[:space:]]$simple)?"
37
+ tag_ref="([[:space:]:+]((refs/)?tags/|v[0-9])|[[:space:]]--((follow-)?tags|mirror)([[:space:]=]|$))"
38
+
39
+ if [[ $text =~ $dispatch$simple($environment_field|$unreadable_inputs) || $text =~ $environment_field$simple$dispatch ]]; then
40
+ block "$release_gate"
9
41
  fi
10
- exit 0
42
+ if [[ $text =~ $railway[[:space:]](-[[:alpha:]]*e([[:space:]]+|=)?|--environment([[:space:]]+|=))$prod || $text =~ $railway[[:space:]](env|environment)([[:space:]]+[[:alpha:]]+)?[[:space:]]+$prod ]]; then
43
+ block "$release_gate"
44
+ fi
45
+ if [[ $text =~ $git_push$tag_ref || $(tr '[:upper:]' '[:lower:]' <<< "$text") == *push.followtags* ]]; then
46
+ block "$release_gate"
47
+ fi
48
+ allow
@@ -76,7 +76,8 @@
76
76
  {
77
77
  "type": "command",
78
78
  "command": "\"$CLAUDE_PROJECT_DIR\"/.agents/hooks/agent-production-gate.sh",
79
- "timeout": 10
79
+ "timeout": 10,
80
+ "onFailure": "block"
80
81
  }
81
82
  ]
82
83
  },
@@ -29,7 +29,8 @@
29
29
  },
30
30
  {
31
31
  "command": ".agents/hooks/agent-production-gate.sh",
32
- "timeout": 10
32
+ "timeout": 10,
33
+ "failClosed": true
33
34
  }
34
35
  ],
35
36
  "beforeMCPExecution": [
@@ -6,12 +6,14 @@ type GateCase = {
6
6
  gate: string;
7
7
  input: Record<string, unknown>;
8
8
  name: string;
9
+ releaseApproval?: string;
9
10
  };
10
11
 
11
12
  const shellGate = ".agents/hooks/agent-shell-gate.sh";
12
13
  const suppressionGate = ".agents/hooks/agent-suppression-gate.sh";
13
14
  const constitutionGate = ".cursor/hooks/agent-constitution-gate.sh";
14
15
  const mcpGate = ".agents/hooks/agent-mcp-gate.sh";
16
+ const productionGate = ".agents/hooks/agent-production-gate.sh";
15
17
  const sourceFile = "src/example.ts";
16
18
 
17
19
  const blockedCommands = [
@@ -189,6 +191,101 @@ const allowedCommands = [
189
191
  'gh api repos/next-friday/example/issues/1/comments -f body="APPROVED by QA"',
190
192
  ];
191
193
 
194
+ const productionCommands = [
195
+ "gh workflow run deploy.yml -f environment=prod -f commit_sha=abc",
196
+ "gh workflow run Deploy --field 'environment=prod'",
197
+ "gh workflow run deploy.yml -fenvironment=prod",
198
+ "gh workflow run deploy.yml --field=environment=prod",
199
+ 'gh workflow run deploy.yml -f "environment=prod"',
200
+ "gh workflow run deploy.yml -F environment=@environment.txt",
201
+ "gh workflow run deploy.yml --json",
202
+ "gh workflow run deploy.yml --json=true",
203
+ "for e in uat prod; do gh workflow run deploy.yml -f environment=$e -f commit_sha=abc; done",
204
+ "env=prod; gh workflow run deploy.yml -f environment=$env",
205
+ "gh workflow run deploy.yml -f environment=$(echo prod)",
206
+ 'gh workflow run deploy.yml -f "environment=$TARGET"',
207
+ "gh api -X POST repos/o/r/actions/workflows/deploy.yml/dispatches -f ref=main -f 'inputs[environment]=prod'",
208
+ "gh api repos/o/r/actions/workflows/deploy.yml/dispatches/ -f inputs[environment]=prod",
209
+ "gh api -f 'inputs[environment]=prod' repos/o/r/actions/workflows/deploy.yml/dispatches",
210
+ "gh api repos/o/r/actions/workflows/1/dispatches --input body.json",
211
+ "gh api repos/o/r/actions/workflows/1/dispatches -f inputs[environment]=$E",
212
+ "bash -c 'gh workflow run deploy.yml -f environment=prod'",
213
+ "railway up --ci --environment prod",
214
+ "railway up -e prod",
215
+ "railway up -e=prod",
216
+ "railway up -eprod",
217
+ "railway up -ce prod",
218
+ "railway up -ceprod",
219
+ "railway redeploy -ye prod",
220
+ "railway up -s api -e prod",
221
+ "railway up --environment=prod",
222
+ 'railway up -e "$ENV"',
223
+ "for e in uat prod; do railway up -e $e; done",
224
+ "railway env prod",
225
+ "railway env link prod",
226
+ "railway environment prod",
227
+ "railway environment delete prod --yes",
228
+ "railway link -e prod",
229
+ "railway logs -e prod",
230
+ "railway variables --set A=b -e prod",
231
+ "npx @railway/cli up -e prod",
232
+ "pnpm dlx @railway/cli up -e prod",
233
+ "/usr/local/bin/railway up -e prod",
234
+ "timeout 60 railway up -e prod",
235
+ "eval railway up -e prod",
236
+ "git push origin v1.0.0",
237
+ "git push origin HEAD:refs/tags/v1.0.0",
238
+ "git push --tags",
239
+ "git push --follow-tags origin main",
240
+ "git -C . push origin v2.3.4",
241
+ "npx @railway/cli@5.71.0 up -e prod",
242
+ "npx @railway/cli@latest up --environment prod",
243
+ "railway up -e pr''od",
244
+ "gh workflow run deploy.yml -f environment=pr''od",
245
+ "gh workflow run deploy.yml -f environment=p\\rod",
246
+ 'gh workflow run deploy.yml -f "envi"ronment=prod',
247
+ "git push origin 'refs/tags/*'",
248
+ 'git push origin "refs/tags/v*"',
249
+ "git push --mirror",
250
+ "git -c push.followTags=true push origin main",
251
+ "git config push.followTags true && git push",
252
+ "gh workflow run deploy.yml 2>&1 -f environment=prod",
253
+ "railway up 2>&1 -e prod",
254
+ "git push origin tags/v1.0.0",
255
+ "git --git-dir .git push origin v1.0.0",
256
+ "gh workflow run deploy.yml \\\n -f environment=prod \\\n -f commit_sha=abc",
257
+ "railway up \\\n --environment prod",
258
+ "git push \\\n origin v1.0.0",
259
+ "railway up &>/dev/null -e prod",
260
+ "git push &>/dev/null origin v1.0.0",
261
+ "cd a\\\\\ngit push origin v1.0.0",
262
+ ];
263
+
264
+ const nonProductionCommands = [
265
+ "gh workflow run deploy.yml -f environment=uat -f commit_sha=abc",
266
+ "gh api repos/o/r/actions/workflows/deploy.yml/dispatches -f 'inputs[environment]=uat'",
267
+ "railway up -e uat",
268
+ "railway up -e prod-eu",
269
+ "railway environment uat",
270
+ "railway status",
271
+ "gh workflow list",
272
+ "gh run list --workflow deploy.yml",
273
+ "gh api repos/o/r/environments/prod",
274
+ "git push -u origin feature/v2-ui",
275
+ "git push origin main",
276
+ 'git commit -m "release v1.0" && git push',
277
+ 'git commit -m "docs(x): deploy to production"',
278
+ 'gh pr create --title "ci(x): promote to prod" --body "run gh workflow run later"',
279
+ "echo railway",
280
+ "git tag v1.0.0",
281
+ 'git stash push -m "wip v2"',
282
+ 'git commit -m "fix(ci): push v2 images"',
283
+ 'git commit -m "fix: railway env uat and prod split"',
284
+ "git push -u origin HEAD",
285
+ "git push origin feature/push-v2",
286
+ "pnpm verify 2>&1 | tail && git push",
287
+ ];
288
+
192
289
  const mcpTools = {
193
290
  blocked: [
194
291
  "mcp__github__merge_pull_request",
@@ -273,6 +370,47 @@ const cases: GateCase[] = [
273
370
  expectedPermission: "allow" as const,
274
371
  name: `shell: ${command}`,
275
372
  })),
373
+ ...productionCommands.flatMap(command => [
374
+ {
375
+ gate: productionGate,
376
+ input: {
377
+ command,
378
+ },
379
+ expectedExit: 2 as const,
380
+ name: `Cursor production: ${command}`,
381
+ },
382
+ {
383
+ gate: productionGate,
384
+ input: {
385
+ tool_input: {
386
+ command,
387
+ },
388
+ },
389
+ expectedExit: 2 as const,
390
+ name: `Claude production: ${command}`,
391
+ },
392
+ ]),
393
+ {
394
+ gate: productionGate,
395
+ releaseApproval: "CHG-1",
396
+ expectedPermission: "allow",
397
+ name: "production with RELEASE_APPROVAL: railway up -e prod",
398
+ expectedExit: 0,
399
+ input: {
400
+ tool_input: {
401
+ command: "railway up -e prod",
402
+ },
403
+ },
404
+ },
405
+ ...nonProductionCommands.map(command => ({
406
+ gate: productionGate,
407
+ input: {
408
+ command,
409
+ },
410
+ expectedExit: 0 as const,
411
+ expectedPermission: "allow" as const,
412
+ name: `production: ${command}`,
413
+ })),
276
414
  ...mcpTools.blocked.map(tool => ({
277
415
  gate: mcpGate,
278
416
  input: {
@@ -399,6 +537,7 @@ const failures = cases.filter(gateCase => {
399
537
  ...process.env,
400
538
  CLAUDE_PROJECT_DIR: process.cwd(),
401
539
  CURSOR_PROJECT_DIR: process.cwd(),
540
+ RELEASE_APPROVAL: gateCase.releaseApproval,
402
541
  },
403
542
  input: JSON.stringify(gateCase.input),
404
543
  });