@wirecat/cli-template 0.1.0 → 0.1.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/CHANGELOG.md CHANGED
@@ -3,6 +3,18 @@
3
3
  Notable changes to `@wirecat/cli-template`. One section per version, newest first; versions follow
4
4
  [semantic versioning](https://semver.org), so before `1.0.0` the skeleton may still change.
5
5
 
6
+ ## 0.1.1 — 25.10.2026
7
+
8
+ ### Fixed
9
+
10
+ - **`npx @wirecat/cli-template new` works.** It stopped with `template.json lists ".gitignore", but the package has
11
+ no such file`, because npm renames `.gitignore` when it installs a package. The package now ships the file under
12
+ another name, and `new` and `upgrade` write it as `.gitignore`.
13
+ - **`new` and `upgrade` work on Windows.** `new` now marks `bin/release` and the other scripts as executable in
14
+ git, so a CLI made on Windows can still run them on Linux and macOS. `upgrade` no longer reads every file as
15
+ edited when git checked it out with Windows line endings, and it downloads the earlier template version without
16
+ needing a shell for `npm`.
17
+
6
18
  ## 0.1.0 — 25.10.2026
7
19
 
8
20
  ### Added
@@ -120,6 +120,23 @@
120
120
  "!**/fixtures/**"
121
121
  ]
122
122
  },
123
+ {
124
+ "path": "./config/wirecat/no-sqlite-driver.grit",
125
+ "includes": [
126
+ "**",
127
+ "!**/*.test.*",
128
+ "!**/*.spec.*",
129
+ "!**/test/**",
130
+ "!**/tests/**",
131
+ "!**/__tests__/**",
132
+ "!**/fixtures/**",
133
+ "!**/testing/**",
134
+ "!**/scripts/**",
135
+ "!**/bench/**",
136
+ "!**/performance/**",
137
+ "!**/contracts/**"
138
+ ]
139
+ },
123
140
  {
124
141
  "path": "./config/wirecat/no-unbounded-spread.grit",
125
142
  "includes": [
@@ -2,7 +2,7 @@
2
2
  "source": "https://github.com/WireCatLabs/community",
3
3
  "revision": "2b0f124f6375939bba10c28a7f091923323e9a13",
4
4
  "files": {
5
- "biome.base.json": "5d265b71896cdb0454260fa8d897bd517f61de512f16831e5d5273f29775f9f3",
5
+ "biome.base.json": "63760337a2e68522ad75e0ba163635785a01ec34dbd3cf338a09a4145932cf2a",
6
6
  "cspell.json": "8241122ed4d461976a838099b265ef8f2689a066608257b6fdc250d9b49b58aa",
7
7
  "lefthook.yml": "2f65a2e3103006d43f6d6f0a07ab333c132286157763222a59a5a22676acd391",
8
8
  "markdownlint.json": "5ed7d6980054c539f3f3c0e47a090f1a725b15be2c486a769a836bc6cd48ad2d",
@@ -14,7 +14,8 @@
14
14
  "release-lock-info.mjs": "4abab1f01ceee7d4c709222e923c9a6fc634bcf7b8b64d745a9ac495993310e0",
15
15
  "release-lock.sh": "bef364092c4ab595f3b9b4130e23570c8306223228cdfa3e9384230f3b7f4a49",
16
16
  "rumdl.toml": "048b929176f13234324f5825d1e811397f1491b9cb6684a1953e268e2c2ed863",
17
- "check.mjs": "34b266411eaab7707030915f645308e86784b1574b21fa44ca974861a90c9ce1"
17
+ "check.mjs": "34b266411eaab7707030915f645308e86784b1574b21fa44ca974861a90c9ce1",
18
+ "no-sqlite-driver.grit": "19e1f510a88ddd9df44d6db43c1c9b603c3e5bbec03a3f65305b72eac7f09389"
18
19
  },
19
20
  "packageManager": "pnpm@11.20.0",
20
21
  "node": ">=22",
@@ -29,6 +30,8 @@
29
30
  },
30
31
  "fileRevisions": {
31
32
  "release-lock.sh": "49e630a99d758e3e113749bef2049b258c7262e3",
32
- "release-lock-info.mjs": "49e630a99d758e3e113749bef2049b258c7262e3"
33
+ "release-lock-info.mjs": "49e630a99d758e3e113749bef2049b258c7262e3",
34
+ "biome.base.json": "06c612c609d870f0ba00010612e0413f65340408",
35
+ "no-sqlite-driver.grit": "06c612c609d870f0ba00010612e0413f65340408"
33
36
  }
34
37
  }
@@ -0,0 +1,15 @@
1
+ language js
2
+
3
+ or {
4
+ `import $_ from $source`,
5
+ `import $source`,
6
+ `export { $_ } from $source`,
7
+ `export * from $source`,
8
+ `export * as $_ from $source`,
9
+ `export type { $_ } from $source`,
10
+ `import($source)`,
11
+ `require($source)`
12
+ } where {
13
+ $source <: r"^[\"'`](?:node:sqlite|bun:sqlite|better-sqlite3|drizzle-orm)(?:/.*)?[\"'`]$",
14
+ register_diagnostic(span=$source, message="The shared store belongs to cli-core (WC-STORE-5): a CLI opens no SQLite driver and writes no SQL against it. Ask cli-core for a store method.", severity="error")
15
+ }
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.1.0";
1
+ export declare const VERSION = "0.1.1";
2
2
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = "0.1.0";
1
+ export const VERSION = "0.1.1";
2
2
  //# sourceMappingURL=version.js.map
@@ -39,4 +39,6 @@ coverage/
39
39
  /.claude/workflows
40
40
 
41
41
  *.tgz
42
+ # The package's copy of this file, made by kit:build.
43
+ /gitignore
42
44
  /CLEANUP.md
package/kit/README.md CHANGED
@@ -116,8 +116,12 @@ A conflict leaves git markers, and `template.json` keeps the new state under `pe
116
116
  - `"bin": { "cli-template": "kit/dist/main.js" }`, and `kit/dist` in `files`. No ignore file inside `kit/`: npm
117
117
  applies it and drops `kit/dist` from the package. npm also never packs `pnpm-lock.yaml`, so it is not a seed; the
118
118
  new CLI makes its own on `pnpm install`.
119
- - Every template-owned file and seed in `files`. npm drops a nested `.gitignore` and any `.npmrc`; the test in
120
- `kit/test/package.test.ts` runs the bin from a real tarball to catch that.
121
- - `kit:build` (`tsc -p kit`) run by `build`, because `bin/release` publishes with `--ignore-scripts`. Node does not
122
- strip types under `node_modules`, so the bin must be JavaScript.
119
+ - Every template-owned file and seed in `files`. npm drops a nested `.gitignore` and any `.npmrc` when it packs.
120
+ When it installs a package, it renames `.gitignore` to `.npmignore`. So the package ships the root `.gitignore` as
121
+ `gitignore`, and `new` and `upgrade` read it back as `.gitignore`. The test in `kit/test/package.test.ts` runs the
122
+ bin from a tarball installed with `npm install` to catch that.
123
+ - `kit:build` (`tsc -p kit`, then the copy to `gitignore`) run by `build`, because `bin/release` and CI pack with
124
+ `--ignore-scripts`. Node does not strip types under `node_modules`, so the bin must be JavaScript.
125
+ - `pnpm kit:e2e` packs this repository, installs the tarball with npm, runs `new` and then `pnpm install` and
126
+ `pnpm ci:quick` in the new CLI. It needs the network and takes a few minutes, so it is not part of `pnpm test`.
123
127
  - `kit/test/**/*.test.ts` in vitest's `include`; `kit/dist` is ignored by the root `**/dist` rules of Biome and git.
package/kit/dist/new.js CHANGED
@@ -5,7 +5,7 @@ import { createInterface } from "node:readline/promises";
5
5
  import { promisify } from "node:util";
6
6
  import { ask, choiceList, INTERVIEW, labelOf, NAME_QUESTIONS, parseAnswer } from "./interview.js";
7
7
  import { MANIFEST, sha256 } from "./manifest.js";
8
- import { renderTemplate } from "./source.js";
8
+ import { PACKED_AS, renderTemplate } from "./source.js";
9
9
  const run = promisify(execFile);
10
10
  const REFERENCES = {
11
11
  messenger: ["whatsapp-cli", "tg-cli", "max-cli"],
@@ -140,7 +140,7 @@ export const cliPackageJson = (text, names) => {
140
140
  next.bin = Object.fromEntries(Object.entries(pkg.bin).filter(([, value]) => !isKit(value)));
141
141
  }
142
142
  if (Array.isArray(pkg.files))
143
- next.files = pkg.files.filter((entry) => !isKit(entry));
143
+ next.files = pkg.files.filter((entry) => !isKit(entry) && !Object.values(PACKED_AS).includes(entry));
144
144
  if (typeof pkg.scripts === "object" && pkg.scripts) {
145
145
  next.scripts = Object.fromEntries(Object.entries(pkg.scripts)
146
146
  .filter(([key]) => !key.startsWith("kit:"))
@@ -192,6 +192,12 @@ const writeOut = async (dir, path, content, mode = 0o644) => {
192
192
  await writeFile(full, content);
193
193
  await chmod(full, mode);
194
194
  };
195
+ // Git for Windows sets core.fileMode=false, so a later `git add -A` there records every file as 644; a mode set
196
+ // in the index survives that add.
197
+ export const markExecutable = async (cwd, paths) => {
198
+ if (paths.length)
199
+ await run("git", ["add", "--chmod=+x", "--", ...paths], { cwd });
200
+ };
195
201
  export const manualSteps = (names) => [
196
202
  `1. Create the repository ${names.owner}/${names.name} and its main ruleset (cli-base docs/standards/new-cli.md).`,
197
203
  `2. Set npm trusted publishing for ${names.package}, naming release.yml (WC-REL-11), and add any secrets.`,
@@ -232,6 +238,7 @@ export const runNew = async (cwd, source, args, io) => {
232
238
  };
233
239
  await writeOut(target, MANIFEST, `${JSON.stringify(manifest, null, 2)}\n`);
234
240
  await run("git", ["init", "-q", "-b", "main"], { cwd: target });
241
+ await markExecutable(target, [...rendered].filter(([, file]) => file.mode & 0o111).map(([path]) => path));
235
242
  const warnings = languageWarnings(answers.contentLang ?? []);
236
243
  return [
237
244
  `Created ${target} from ${source.name}@${source.version}.`,
@@ -19,6 +19,7 @@ export const renamer = (placeholders, names) => {
19
19
  return (text) => text.replace(pattern, (match) => table.get(match) ?? match);
20
20
  };
21
21
  const isBinary = (content) => content.subarray(0, 8000).includes(0);
22
+ export const withLf = (content) => isBinary(content) || !content.includes(13) ? content : Buffer.from(content.toString("utf8").replaceAll("\r\n", "\n"));
22
23
  export const renameContent = (rename, content) => isBinary(content) ? content : Buffer.from(rename(content.toString("utf8")), "utf8");
23
24
  const KIT_CONFIG = /(^|\/)(vitest\.config\.ts|biome\.json|cspell\.json|tsconfig[^/]*\.json|\.markdownlint-cli2\.jsonc)$/;
24
25
  // The template's configs name kit/ so its own checks cover the copy tool; a CLI has no kit/, and the
@@ -1,7 +1,8 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { existsSync } from "node:fs";
2
3
  import { mkdtemp, readdir, readFile, rm, stat } from "node:fs/promises";
3
4
  import { tmpdir } from "node:os";
4
- import { join, relative, sep } from "node:path";
5
+ import { dirname, join, relative, sep } from "node:path";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import { promisify } from "node:util";
7
8
  import { classify, MANIFEST, parseTemplateManifest, readJson, } from "./manifest.js";
@@ -18,6 +19,15 @@ export const loadSource = async (root) => {
18
19
  return { root, name: pkg.name, version: pkg.version, manifest };
19
20
  };
20
21
  const SKIP_DIRS = new Set([".git", "node_modules"]);
22
+ // npm renames .gitignore to .npmignore when it installs a package, so the package ships it as gitignore.
23
+ export const PACKED_AS = { ".gitignore": "gitignore" };
24
+ const onDisk = async (root, path) => {
25
+ const packed = PACKED_AS[path];
26
+ const full = join(root, path);
27
+ if (!packed)
28
+ return full;
29
+ return (await stat(full).catch(() => undefined)) ? full : join(root, packed);
30
+ };
21
31
  const walk = async (dir) => {
22
32
  const entries = await readdir(dir, { withFileTypes: true });
23
33
  const nested = await Promise.all(entries.map(async (entry) => {
@@ -29,7 +39,7 @@ const walk = async (dir) => {
29
39
  return nested.flat();
30
40
  };
31
41
  const listEntry = async (root, entry) => {
32
- const path = join(root, entry);
42
+ const path = await onDisk(root, entry);
33
43
  const info = await stat(path).catch(() => undefined);
34
44
  if (!info)
35
45
  throw new Error(`${MANIFEST} lists "${entry}", but the package has no such ${entry.endsWith("/") ? "folder" : "file"}`);
@@ -45,26 +55,40 @@ export const listTemplate = async (source) => {
45
55
  return kind ? [[path, kind]] : [];
46
56
  }));
47
57
  };
58
+ // npm cannot set the executable bit when it installs on Windows, so a shebang stands in for it there. A .ts or .js
59
+ // entry point carries one for node, not for the shell.
60
+ const isExecutable = (path, content, mode) => (mode & 0o111) !== 0 || (content.subarray(0, 2).toString() === "#!" && !/\.[cm]?[jt]sx?$/.test(path));
48
61
  export const renderTemplate = async (source, names) => {
49
62
  const rename = renamer(source.manifest.placeholders, names);
50
63
  const listed = await listTemplate(source);
51
64
  const rendered = await Promise.all([...listed].map(async ([path, kind]) => {
52
- const full = join(source.root, path);
65
+ const full = await onDisk(source.root, path);
53
66
  const [content, info] = await Promise.all([readFile(full), stat(full)]);
54
67
  const renamed = renameContent(rename, stripKit(path, content));
55
- return [rename(path), { kind, content: renamed, mode: info.mode & 0o777 }];
68
+ const mode = isExecutable(path, content, info.mode) ? 0o755 : 0o644;
69
+ return [rename(path), { kind, content: renamed, mode }];
56
70
  }));
57
71
  return new Map(rendered);
58
72
  };
73
+ // On Windows npm is npm.cmd, which execFile cannot start without a shell; npm-cli.js beside node runs anywhere.
74
+ export const npmCommand = (args) => {
75
+ const home = dirname(process.execPath);
76
+ const cli = [
77
+ join(home, "node_modules/npm/bin/npm-cli.js"),
78
+ join(home, "../lib/node_modules/npm/bin/npm-cli.js"),
79
+ ].find((path) => existsSync(path));
80
+ return cli ? [process.execPath, [cli, ...args]] : ["npm", args];
81
+ };
59
82
  export const fetchPublished = async (name, version) => {
60
83
  const dir = await mkdtemp(join(tmpdir(), "cli-template-"));
61
- const { stdout } = await run("npm", ["pack", `${name}@${version}`, "--silent", "--pack-destination", dir]).catch((error) => {
84
+ const { stdout } = await run(...npmCommand(["pack", `${name}@${version}`, "--silent", "--pack-destination", dir])).catch((error) => {
62
85
  throw new Error(`could not download ${name}@${version} from npm, which upgrade needs as the merge base: ${error.message}`);
63
86
  });
64
87
  const tarball = stdout.trim().split("\n").pop() ?? "";
65
88
  const dispose = () => rm(dir, { recursive: true, force: true });
66
89
  try {
67
- await run("tar", ["-xzf", join(dir, tarball), "-C", dir]);
90
+ // GNU tar reads "C:" in a path as a remote host, so the archive is named relative to its folder.
91
+ await run("tar", ["-xzf", tarball], { cwd: dir });
68
92
  return { source: await loadSource(join(dir, "package")), dispose };
69
93
  }
70
94
  catch (error) {
@@ -5,10 +5,13 @@ import { dirname, join } from "node:path";
5
5
  import { createInterface } from "node:readline/promises";
6
6
  import { promisify } from "node:util";
7
7
  import { classify, MANIFEST, readCliManifest, sha256 } from "./manifest.js";
8
+ import { markExecutable } from "./new.js";
9
+ import { withLf } from "./rename.js";
8
10
  import { renderTemplate } from "./source.js";
9
11
  const run = promisify(execFile);
10
12
  const MODES = ["apply", "replace", "skip"];
11
- const readMaybe = (path) => readFile(path).catch(() => undefined);
13
+ // Git for Windows checks text out with CRLF by default; the template's hashes and merges are of LF text.
14
+ const readMaybe = (path) => readFile(path).then(withLf, () => undefined);
12
15
  const requireCleanTree = async (cwd) => {
13
16
  const { stdout } = await run("git", ["status", "--porcelain"], { cwd }).catch(() => {
14
17
  throw new Error(`${cwd} is not a git repository; upgrade needs one so every change can be seen and undone`);
@@ -173,11 +176,14 @@ export const runUpgrade = async (cwd, target, fetchVersion, options, io) => {
173
176
  };
174
177
  const results = [];
175
178
  const conflicts = [];
179
+ const executable = [];
176
180
  try {
177
181
  for (const change of changes) {
178
182
  const { outcome, content } = await planned(change, await choose(change));
179
183
  if (content)
180
184
  await writeAs(join(cwd, change.path), content, change.ours ? undefined : change.theirs.mode);
185
+ if (content && !change.ours && change.theirs.mode & 0o111)
186
+ executable.push(change.path);
181
187
  if (outcome === "conflict")
182
188
  conflicts.push(change.path);
183
189
  results.push(` ${change.path} (${change.state}): ${outcome}`);
@@ -186,6 +192,7 @@ export const runUpgrade = async (cwd, target, fetchVersion, options, io) => {
186
192
  finally {
187
193
  rl?.close();
188
194
  }
195
+ await markExecutable(cwd, executable);
189
196
  const footer = ["Then re-run community's adopt-standards.py for config/wirecat/, which upgrade never touches."];
190
197
  const files = Object.fromEntries([...owned].map(([path, file]) => [path, sha256(file.content)]));
191
198
  if (conflicts.length) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-template",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Start a new WireCat CLI: a working skeleton on cli-base and cli-core, copied by new and kept current by upgrade",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",
@@ -46,7 +46,7 @@
46
46
  "tsconfig.scripts.json",
47
47
  "tsconfig.test.json",
48
48
  "vitest.config.ts",
49
- ".gitignore"
49
+ "gitignore"
50
50
  ],
51
51
  "scripts": {
52
52
  "build": "tsc --build --force && pnpm kit:build",
@@ -76,8 +76,9 @@
76
76
  "prepare": "lefthook install || true",
77
77
  "prepack": "pnpm build",
78
78
  "docs:spell": "cspell lint --config cspell.json --no-progress README.md",
79
- "kit:build": "tsc -p kit",
80
- "kit:typecheck": "tsc -p kit/tsconfig.test.json"
79
+ "kit:build": "tsc -p kit && node -e \"require('node:fs').copyFileSync('.gitignore', 'gitignore')\"",
80
+ "kit:typecheck": "tsc -p kit/tsconfig.test.json",
81
+ "kit:e2e": "bash kit/e2e.sh"
81
82
  },
82
83
  "dependencies": {
83
84
  "@modelcontextprotocol/server": "2.3.0",
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.1.0"
1
+ export const VERSION = "0.1.1"