@arc-e-tect/api-only-publisher 0.18.5 → 0.18.7

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/README.adoc CHANGED
@@ -6,7 +6,7 @@
6
6
  :source-highlighter: rouge
7
7
  // The released version of this component: its snippets use it. The release
8
8
  // workflow updates it; do not change it by hand.
9
- :api-only-publisher-version: 0.18.5
9
+ :api-only-publisher-version: 0.18.7
10
10
 
11
11
  [NOTE]
12
12
  .About this documentation
@@ -293,6 +293,10 @@ The name becomes file names, a properties key, a Maven artifactId, a git tag and
293
293
  |Broker host
294
294
  |`--broker-host <host[:port]>`
295
295
  |`kafka:9092`. Asked only for a library with AsyncAPI.
296
+
297
+ |Run the Publisher with
298
+ |`--runner <npm\|docker>`
299
+ |`npm`. How the pre-push hook runs `check`: `npx`, from the library's own `package.json`, or the Docker image of this Publisher's version (<<installation,Installation §>>).
296
300
  |===
297
301
 
298
302
  A flag always wins: its question is not asked, and an invalid value, or a flag for a kind the library does not hold, stops `init` before it writes anything.
@@ -305,6 +309,13 @@ What each kind of library gets::
305
309
  * *Both*: both trees, one target building both documents, and one schema they share: `specs/openapi/components/common/schemas/ExampleIdV1.yaml`, which the HTTP response and the event payload both refer to.
306
310
  That is the library's convention in miniature -- a definition used by both protocols is one fragment -- and why the whole source root is staged together (<<what-build-does,What `build` does §>>).
307
311
 
312
+ What every library gets as well::
313
+ A `pre-push` git hook, `.githooks/pre-push` at the top of the repository, that runs `check` before every push (<<version-hook,Checking versions before every push §>>).
314
+ Inside a git repository, `init` also points this clone's git at it, with `git config core.hooksPath .githooks`, unless `core.hooksPath` is already set.
315
+ When the repository's hooks are run by a hook manager -- a `.husky` directory, a `lefthook.yml`, a `.pre-commit-config.yaml`, or a `core.hooksPath` that points somewhere else -- `init` writes no hook, which would compete with it, and prints the command to add to the manager's `pre-push` entry instead.
316
+ A `core.hooksPath` in your global git configuration counts as well, deliberately: setting one for this repository would quietly switch off the hooks you run everywhere else, such as a company-wide secret scanner.
317
+ Add the command to those hooks, or set `core.hooksPath` for this repository yourself if that is what you want.
318
+
308
319
  What it reports::
309
320
  `init` never overwrites what is there unless told to, and says for every file what it did:
310
321
  +
@@ -316,6 +327,8 @@ The message names every slot added -- see <<init-additive,Adding a kind to an ex
316
327
  `differs`:: The file was there, and does not match: where the library has diverged from the scaffold, usually on purpose.
317
328
  It is left alone.
318
329
  `overwrote`:: The file differed, and `--force` replaced it.
330
+ `skipped`:: The pre-push hook was not written, because a hook manager runs the repository's hooks.
331
+ `configured`:: `core.hooksPath` was not set, and now points at `.githooks`.
319
332
  +
320
333
  At a terminal, `--force` first lists the files that still differ -- after any additive update apionly.yaml already got -- and asks once whether to overwrite them; without a terminal, or with `--yes`, it overwrites them without asking.
321
334
 
@@ -487,8 +500,15 @@ The pipeline catches a missing version bump, but only after you have pushed.
487
500
  A git hook catches it before then.
488
501
  A hook is a script that git runs on your machine at a fixed moment.
489
502
  A `pre-push` hook runs when you type `git push`, and if it fails, git sends nothing.
490
- The steps below set one up so that it runs `check`.
491
- They need no hook manager and nothing outside git and the Publisher.
503
+ The hook below runs `check`.
504
+ It needs no hook manager and nothing outside git and the Publisher.
505
+
506
+ `init` writes it, as `.githooks/pre-push` at the top of the repository (<<init,Scaffolding a library §>>).
507
+ `--runner` picks the variant: `npm`, the default, or `docker`.
508
+ When the library is in a subdirectory, the hook goes to the top all the same, because that is where git runs hooks from, and runs `check` in the library's directory.
509
+ A second library in the same repository finds the hook there already and reports it as `differs`: add its `check` to the hook by hand.
510
+
511
+ For a library that already exists, write the hook yourself:
492
512
 
493
513
  . *Create the hook* as `.githooks/pre-push` in the repository, the one that holds `apionly.yaml`.
494
514
  The file has no extension.
@@ -498,11 +518,9 @@ Pick the variant for the way the library runs the Publisher (<<installation,Inst
498
518
  [source,sh]
499
519
  ----
500
520
  #!/bin/sh
501
- # Refuse the push when a target changed without a new version in its version file.
502
- # The branch versioning.baseBranch names in apionly.yaml: main unless it says otherwise.
503
- BASE_BRANCH=main
504
- # The check compares with origin/$BASE_BRANCH and the release tags, so bring them up to date first.
505
- git fetch --quiet --tags origin "$BASE_BRANCH" || echo "pre-push: could not fetch origin/$BASE_BRANCH; checking against what is here" >&2
521
+ # Refuses the push when a published target changed without a new version in its version file.
522
+ # check compares with origin's base branch and the release tags: bring them up to date.
523
+ git fetch --quiet --tags origin || echo "pre-push: could not fetch origin; checking against what is here" >&2
506
524
  exec npx --no-install api-only-publisher check
507
525
  ----
508
526
  +
@@ -510,20 +528,18 @@ exec npx --no-install api-only-publisher check
510
528
  [source,sh,subs="attributes+"]
511
529
  ----
512
530
  #!/bin/sh
513
- # Refuse the push when a target changed without a new version in its version file.
514
- # The branch versioning.baseBranch names in apionly.yaml: main unless it says otherwise.
515
- BASE_BRANCH=main
516
- # The check compares with origin/$BASE_BRANCH and the release tags, so bring them up to date first.
517
- git fetch --quiet --tags origin "$BASE_BRANCH" || echo "pre-push: could not fetch origin/$BASE_BRANCH; checking against what is here" >&2
531
+ # Refuses the push when a published target changed without a new version in its version file.
532
+ # check compares with origin's base branch and the release tags: bring them up to date.
533
+ git fetch --quiet --tags origin || echo "pre-push: could not fetch origin; checking against what is here" >&2
518
534
  exec docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/work" \
519
535
  arcetect/api-only-publisher:{api-only-publisher-version} check
520
536
  ----
521
537
  +
522
- Set `BASE_BRANCH` to the branch `versioning.baseBranch` names, `pre-prod` for the library above.
523
- The hook only fetches that branch; `check` reads which branch to compare with from `apionly.yaml`, so the two must name the same one.
538
+ The hook fetches every branch of `origin`, so it never needs to know which one `versioning.baseBranch` names, and changing that setting needs no change to the hook.
539
+ It fetches on your machine, not in the container: the image has no ssh client and none of your credentials.
524
540
  +
525
541
  Git runs a hook from the top of the repository.
526
- When `apionly.yaml` is in a subdirectory, add `-C <that directory>` after `check`.
542
+ When `apionly.yaml` is in a subdirectory, add `cd <that directory> || exit 1` before the `npx` line, or `-C <that directory>` after `check` in the Docker variant, which needs the whole repository mounted to read its history.
527
543
 
528
544
  . *Make it executable*, and commit it, so that everyone who clones the repository gets it:
529
545
  +
@@ -534,13 +550,13 @@ git add .githooks/pre-push
534
550
  git commit -m "chore: check API versions before every push"
535
551
  ----
536
552
  +
537
- On Windows, `chmod` has no effect.
553
+ On Windows, `chmod` has no effect, and neither does the executable bit `init` sets.
538
554
  Use `git update-index --chmod=+x .githooks/pre-push` before the commit instead.
539
555
  Git for Windows runs the hook with its own `sh`, so the script works there as it is.
540
556
 
541
557
  . *Tell git to use the directory.*
542
558
  Git only looks in `.git/hooks` by default, and that directory is never committed.
543
- Every clone has to run this once:
559
+ Every clone has to run this once; `init` runs it for the clone it scaffolds in:
544
560
  +
545
561
  [source,sh]
546
562
  ----
@@ -549,7 +565,7 @@ git config core.hooksPath .githooks
549
565
  +
550
566
  Put this line in the library's own README, or in its setup script, so nobody has to remember it.
551
567
  A hook manager such as husky or lefthook does this step for you when its dependencies are installed.
552
- If you already use one, call the same `check` command from its `pre-push` entry instead.
568
+ If you already use one, call the same `check` command from its `pre-push` entry instead; `init` prints that command when it finds one.
553
569
 
554
570
  . *Try it.*
555
571
  Change a fragment of a published target and commit it without changing its version file.
@@ -569,9 +585,10 @@ Raise the version, commit, and push again.
569
585
 
570
586
  Some things to know about the hook:
571
587
 
588
+ * It checks every published target, not only those the pushed commits touch: a target that did not change passes, and the whole check takes seconds.
572
589
  * `check` looks at the working tree, and that includes changes you have not committed yet.
573
590
  Commit or stash them before you push, so it checks what you are actually pushing.
574
- * It needs neither credentials nor a network beyond the `git fetch`, and it builds nothing, so it takes seconds.
591
+ * It needs neither credentials nor a network beyond the `git fetch`, and it builds nothing.
575
592
  * `git push --no-verify` skips the hook.
576
593
  The pipeline still runs the same check, so skipping it only means you find out later.
577
594
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arc-e-tect/api-only-publisher",
3
- "version": "0.18.5",
3
+ "version": "0.18.7",
4
4
  "description": "Builds, packs and publishes API description documents from a library of reusable fragments.",
5
5
  "license": "MIT",
6
6
  "author": "Arc-E-Tect",
package/src/cli.js CHANGED
@@ -9,6 +9,7 @@ const { loadFrom, ConfigError } = require("./config");
9
9
  const { build, prepare, BuildError } = require("./pipeline");
10
10
  const { init, scaffold, plan } = require("./init");
11
11
  const { resolveValues } = require("./init-questions");
12
+ const { planHook, setHooksPath, HOOKS_DIR } = require("./init-hook");
12
13
  const { resolvePortfolioValues, writePortfolioSection } = require("./config-command");
13
14
  const { PlaceholderError } = require("./placeholders");
14
15
  const { VersionError } = require("./version");
@@ -34,7 +35,7 @@ Usage:
34
35
  [--title <text>] [--contract-version <version>]
35
36
  [--contact-name <text>] [--contact-url <url>]
36
37
  [--license <spdx-id>] [--license-url <url>]
37
- [--server-url <url>] [--broker-host <host[:port]>]
38
+ [--server-url <url>] [--broker-host <host[:port]>] [--runner <npm|docker>]
38
39
  api-only-publisher config [section] [--yes] [--portfolio-paths <target-prefix|none>]
39
40
  [--portfolio-location <dir>]
40
41
  api-only-publisher build [--target <name>]... [--pre-release <ids>] [--openapi|--asyncapi]
@@ -64,6 +65,9 @@ Options:
64
65
  --title, --contract-version, --contact-name, --contact-url, --license,
65
66
  --license-url, --server-url, --broker-host
66
67
  init only: the value of that question; a flag always wins.
68
+ --runner <npm|docker>
69
+ init only: how the pre-push hook init writes runs check.
70
+ Default: npm.
67
71
  --portfolio-paths <target-prefix|none>, --portfolio-location <dir>
68
72
  config only: the value of that question; a flag always wins.
69
73
  --since <ref> changed: the git ref to compare the working tree against.
@@ -94,7 +98,7 @@ function parseArgs(argv) {
94
98
  const INIT_VALUES = {
95
99
  "--title": "title", "--contract-version": "contractVersion", "--contact-name": "contactName",
96
100
  "--contact-url": "contactUrl", "--license": "license", "--license-url": "licenseUrl",
97
- "--server-url": "serverUrl", "--broker-host": "brokerHost",
101
+ "--server-url": "serverUrl", "--broker-host": "brokerHost", "--runner": "runner",
98
102
  };
99
103
  const CONFIG_VALUES = { "--portfolio-paths": "paths", "--portfolio-location": "location" };
100
104
  const positional = [];
@@ -232,26 +236,52 @@ async function runInit(options, positional, io, log) {
232
236
  }
233
237
 
234
238
  log(`Scaffolding a specification library in ${dir}`);
239
+ const hook = planHook(dir, values.runner, require("../package.json").version);
240
+ const scaffolded = { ...scaffold(values), ...hook.files };
235
241
  let force = options.force;
236
242
  if (terminal && force) {
237
243
  // Asked, --force means "after showing me": it overwrites what differs only
238
244
  // once the list has been seen and agreed to.
239
- const differing = plan(dir, scaffold(values), values, portfolio).filter((entry) => entry.status === "differs");
245
+ const differing = plan(dir, scaffolded, values, portfolio).filter((entry) => entry.status === "differs");
240
246
  if (differing.length > 0) {
241
247
  output.write(`These files differ from the scaffold:\n${differing.map((d) => ` ${d.rel}\n`).join("")}`);
242
248
  const answer = await terminal.question(`Overwrite these ${differing.length} file(s)? [y/N] `);
243
249
  force = /^y(es)?$/i.test(answer.trim());
244
250
  }
245
251
  }
246
- init(dir, { values, force, log, portfolio });
252
+ init(dir, { values, force, log, portfolio, extra: hook.files, executable: hook.executable });
247
253
  if (portfolioStatusThisRun === "present") log(" present portfolio (already configured; init never changes it)");
254
+ const next = reportHook(hook, log);
248
255
  log(`\nNext: api-only-publisher build -C ${dir}`);
256
+ for (const line of next) log(line);
249
257
  } finally {
250
258
  if (terminal) terminal.close();
251
259
  }
252
260
  return 0;
253
261
  }
254
262
 
263
+ /**
264
+ * Reports what init did about the pre-push hook beyond writing its file, and does
265
+ * the one thing left: pointing this clone's git at .githooks, when nothing else
266
+ * already decides where its hooks are. README.adoc#version-hook.
267
+ *
268
+ * @returns {string[]} the lines to print after "Next:", for what init could not do itself
269
+ */
270
+ function reportHook(hook, log) {
271
+ if (hook.manager) {
272
+ log(` skipped ${HOOKS_DIR}/pre-push (${hook.manager} runs this repository's hooks)`);
273
+ return [`Add to its pre-push hook: ${hook.command}`];
274
+ }
275
+ if (!hook.repository) {
276
+ return [`Once this is a git repository, run the pre-push hook: git config core.hooksPath ${HOOKS_DIR}`];
277
+ }
278
+ if (hook.hooksPath === "unset") {
279
+ setHooksPath(hook.repository);
280
+ log(` configured core.hooksPath=${HOOKS_DIR} (this clone runs the pre-push hook)`);
281
+ }
282
+ return [`Every other clone runs the pre-push hook after: git config core.hooksPath ${HOOKS_DIR}`];
283
+ }
284
+
255
285
  const CONFIGURABLE_SECTIONS = ["portfolio"];
256
286
 
257
287
  /**
@@ -0,0 +1,135 @@
1
+ "use strict";
2
+
3
+ // The pre-push hook `init` offers: `check` on the developer's own machine, before a
4
+ // target that changed without a new version leaves it. README.adoc#version-hook.
5
+ //
6
+ // Unlike the rest of the scaffold, where the hook goes and whether it is written at
7
+ // all depends on the repository the library is in, so this is where git is read:
8
+ //
9
+ // - The hook lives in .githooks/ at the top of the repository, committed, because
10
+ // .git/hooks never is; core.hooksPath points git at it, once per clone.
11
+ // - A repository whose hooks a manager runs -- husky, lefthook, pre-commit, or a
12
+ // core.hooksPath that points elsewhere -- gets no hook file, which would compete
13
+ // with the manager, only the command to add to its pre-push entry.
14
+ // - The hook fetches on the host, never inside the Docker image: the image has no
15
+ // ssh client and no credentials, and origin is what check compares with.
16
+
17
+ const fs = require("fs");
18
+ const path = require("path");
19
+ const { execFileSync } = require("child_process");
20
+
21
+ const HOOKS_DIR = ".githooks";
22
+ const IMAGE = "arcetect/api-only-publisher";
23
+
24
+ function tryGit(args, cwd) {
25
+ try {
26
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ /** The closest directory at or above `dir` that exists: init may be scaffolding one that does not yet. */
33
+ function existingAncestor(dir) {
34
+ let current = dir;
35
+ while (!fs.existsSync(current)) current = path.dirname(current);
36
+ return current;
37
+ }
38
+
39
+ /** A path as a single-quoted shell word. */
40
+ function shellQuote(text) {
41
+ return /^[A-Za-z0-9_./-]+$/.test(text) ? text : `'${text.replace(/'/g, "'\\''")}'`;
42
+ }
43
+
44
+ /** The image tag the Docker variant runs: this Publisher's own version, or latest for a build that was never released. */
45
+ function imageTag(version) {
46
+ return /^\d+\.\d+\.\d+$/.test(version) && version !== "0.0.0" ? version : "latest";
47
+ }
48
+
49
+ /**
50
+ * The command a pre-push hook runs, from the top of the repository, for a library
51
+ * `sub` below it ("" when the library is at the top).
52
+ *
53
+ * npx finds the Publisher in the library's own node_modules, so the npm variant
54
+ * changes into the library first; the Docker variant mounts the whole repository,
55
+ * .git included, and names the library with -C.
56
+ */
57
+ function checkCommand(runner, sub, version) {
58
+ if (runner === "docker") {
59
+ return `docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/work" ${IMAGE}:${imageTag(version)} check` +
60
+ (sub ? ` -C ${shellQuote(sub)}` : "");
61
+ }
62
+ return `${sub ? `cd ${shellQuote(sub)} && ` : ""}npx --no-install api-only-publisher check`;
63
+ }
64
+
65
+ const FETCH = `git fetch --quiet --tags origin || echo "pre-push: could not fetch origin; checking against what is here" >&2`;
66
+
67
+ /** The hook script itself. */
68
+ function hookScript(runner, sub, version) {
69
+ const lines = [
70
+ "#!/bin/sh",
71
+ "# Written by api-only-publisher init: refuses the push when a published target",
72
+ "# changed without a new version in its version file. `git push --no-verify` skips it.",
73
+ "# check compares with origin's base branch and the release tags: bring them up to date.",
74
+ FETCH,
75
+ ];
76
+ // A failed cd must stop the push, not run check somewhere else.
77
+ if (runner === "npm" && sub) lines.push(`cd ${shellQuote(sub)} || exit 1`, `exec ${checkCommand(runner, "", version)}`);
78
+ else lines.push(`exec ${checkCommand(runner, sub, version)}`);
79
+ return lines.join("\n") + "\n";
80
+ }
81
+
82
+ /** The hook manager this repository already uses, by name, or null. */
83
+ function hookManager(top, hooksPath) {
84
+ if (fs.existsSync(path.join(top, ".husky"))) return "husky";
85
+ for (const file of ["lefthook.yml", "lefthook.yaml", ".lefthook.yml", ".lefthook.yaml"]) {
86
+ if (fs.existsSync(path.join(top, file))) return "lefthook";
87
+ }
88
+ if (fs.existsSync(path.join(top, ".pre-commit-config.yaml"))) return "pre-commit";
89
+ if (hooksPath && path.normalize(hooksPath).replace(/[\\/]+$/, "") !== HOOKS_DIR) return `core.hooksPath (${hooksPath})`;
90
+ return null;
91
+ }
92
+
93
+ /**
94
+ * What init does about the hook for a library in `dir`, run with `runner`.
95
+ *
96
+ * @returns {{
97
+ * files: Object<string, string>, the hook file, keyed by its path relative to dir; empty under a manager
98
+ * executable: string[], which of those files to make executable
99
+ * manager: string|null, the hook manager found, if any
100
+ * command: string, what a manager's pre-push entry should run
101
+ * repository: string|null, the top of the git repository, or null outside one
102
+ * hooksPath: "unset"|"set"|null, whether init should set core.hooksPath; null when it has nothing to do
103
+ * }}
104
+ */
105
+ function planHook(dir, runner, version) {
106
+ const top = tryGit(["rev-parse", "--show-toplevel"], existingAncestor(dir));
107
+ const repository = top === null ? null : fs.realpathSync(top);
108
+ // Real paths, as git answers with: a directory reached through a symlink -- /var on macOS -- would never match.
109
+ const ancestor = existingAncestor(dir);
110
+ const libraryDir = path.join(fs.realpathSync(ancestor), path.relative(ancestor, dir));
111
+ // Outside a repository, the library is taken to become the top of one.
112
+ const root = repository || libraryDir;
113
+ const sub = path.relative(root, libraryDir).split(path.sep).join("/");
114
+ const hooksPath = repository ? tryGit(["config", "--get", "core.hooksPath"], repository) : null;
115
+ const manager = repository ? hookManager(repository, hooksPath) : null;
116
+ const command = checkCommand(runner, sub, version);
117
+ if (manager) return { files: {}, executable: [], manager, command, repository, hooksPath: null };
118
+
119
+ const rel = path.relative(libraryDir, path.join(root, HOOKS_DIR, "pre-push")).split(path.sep).join("/");
120
+ return {
121
+ files: { [rel]: hookScript(runner, sub, version) },
122
+ executable: [rel],
123
+ manager: null,
124
+ command,
125
+ repository,
126
+ hooksPath: hooksPath ? "set" : "unset",
127
+ };
128
+ }
129
+
130
+ /** Points git at .githooks in this clone. */
131
+ function setHooksPath(repository) {
132
+ execFileSync("git", ["config", "core.hooksPath", HOOKS_DIR], { cwd: repository, stdio: "ignore" });
133
+ }
134
+
135
+ module.exports = { planHook, setHooksPath, imageTag, HOOKS_DIR };
@@ -100,6 +100,14 @@ const QUESTIONS = [
100
100
  },
101
101
  { key: "serverUrl", flag: "--server-url", text: "Production server URL", check: checkUrl, when: hasOpenapi },
102
102
  { key: "brokerHost", flag: "--broker-host", text: "Broker host, with its port", check: checkHost, when: hasAsyncapi, name: "a broker host" },
103
+ {
104
+ // How the pre-push hook runs check: through the library's own npm install, or the image.
105
+ key: "runner", flag: "--runner", text: "Run the Publisher with: npm or docker",
106
+ read: (text) => {
107
+ const runner = text.trim().toLowerCase();
108
+ return ["npm", "docker"].includes(runner) ? { value: runner } : { problem: `'${text}' is not npm or docker` };
109
+ },
110
+ },
103
111
  ];
104
112
 
105
113
  /** Reads an answer to a question: the value, or what is wrong with it. */
package/src/init.js CHANGED
@@ -29,6 +29,7 @@ const DEFAULTS = Object.freeze({
29
29
  licenseUrl: "https://www.apache.org/licenses/LICENSE-2.0.html",
30
30
  serverUrl: "https://api.example.invalid",
31
31
  brokerHost: "kafka:9092",
32
+ runner: "npm",
32
33
  });
33
34
 
34
35
  /** A value as a YAML scalar: plain where YAML reads it back unchanged, quoted where it would not. */
@@ -362,9 +363,15 @@ function plan(targetDir, files, values, portfolio) {
362
363
  * `portfolio`, given, is added the same way, as one more slot: whether the
363
364
  * library is about to have more than one target -- and so whether this is worth
364
365
  * asking about at all -- is decided before init is called, not here.
366
+ *
367
+ * `extra` are files beyond the scaffold, keyed as it is -- the pre-push hook, whose
368
+ * place depends on the repository, which init-hook.js reads -- and written by the
369
+ * same rules; those `executable` names are made executable whenever they are written.
365
370
  */
366
- function init(targetDir, { values = DEFAULTS, force = false, log = () => {}, portfolio = null } = {}) {
367
- const files = scaffold(values);
371
+ function init(targetDir, {
372
+ values = DEFAULTS, force = false, log = () => {}, portfolio = null, extra = {}, executable = [],
373
+ } = {}) {
374
+ const files = { ...scaffold(values), ...extra };
368
375
  const report = { created: [], overwritten: [], identical: [], differing: [], updated: [] };
369
376
  const updates = [];
370
377
 
@@ -386,6 +393,7 @@ function init(targetDir, { values = DEFAULTS, force = false, log = () => {}, por
386
393
  const file = path.join(targetDir, rel);
387
394
  fs.mkdirSync(path.dirname(file), { recursive: true });
388
395
  fs.writeFileSync(file, files[rel]);
396
+ if (executable.includes(rel)) fs.chmodSync(file, 0o755);
389
397
  (status === "missing" ? report.created : report.overwritten).push(rel);
390
398
  }
391
399