@wirecat/cli-template 0.0.0-stage → 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/.github/dependabot.yml +23 -0
- package/.github/workflows/changelog-guard.yml +12 -0
- package/.github/workflows/ci.yml +88 -0
- package/.github/workflows/main.yml +17 -0
- package/.github/workflows/monthly.yml +28 -0
- package/.github/workflows/pr.yml +19 -0
- package/.github/workflows/release.yml +191 -0
- package/.github/workflows/standards.yml +51 -0
- package/.gitleaks.toml +5 -0
- package/.markdownlint-cli2.jsonc +7 -0
- package/.vale.ini +5 -0
- package/AGENTS.md +27 -0
- package/CHANGELOG.md +31 -0
- package/CLAUDE.md +17 -0
- package/LICENSE +204 -0
- package/README.md +52 -2
- package/SECURITY.md +15 -0
- package/bin/release +139 -0
- package/biome.json +6 -0
- package/config/wirecat/LICENSE +204 -0
- package/config/wirecat/biome.base.json +157 -0
- package/config/wirecat/check.mjs +41 -0
- package/config/wirecat/cspell.json +80 -0
- package/config/wirecat/lefthook.yml +26 -0
- package/config/wirecat/manifest.json +37 -0
- package/config/wirecat/markdownlint.json +10 -0
- package/config/wirecat/no-deep-wirecat-import.grit +15 -0
- package/config/wirecat/no-exec-interpolation.grit +17 -0
- package/config/wirecat/no-new-function.grit +8 -0
- package/config/wirecat/no-shell-true.grit +8 -0
- package/config/wirecat/no-sqlite-driver.grit +15 -0
- package/config/wirecat/no-unbounded-spread.grit +10 -0
- package/config/wirecat/release-lock-info.mjs +71 -0
- package/config/wirecat/release-lock.sh +34 -0
- package/config/wirecat/rumdl.toml +9 -0
- package/contracts/help.snap +16 -0
- package/contracts/mcp-tools.json +922 -0
- package/contracts/no-leak.json +3 -0
- package/contracts/plan.json +50 -0
- package/cspell.json +10 -0
- package/dist/bin/app.d.ts +3 -0
- package/dist/bin/app.d.ts.map +1 -0
- package/dist/bin/app.js +14 -0
- package/dist/bin/app.js.map +1 -0
- package/dist/commands/doctor.d.ts +23 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +44 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/mcp.d.ts +3 -0
- package/dist/commands/mcp.d.ts.map +1 -0
- package/dist/commands/mcp.js +36 -0
- package/dist/commands/mcp.js.map +1 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +4 -0
- package/dist/config.js.map +1 -0
- package/dist/identity.d.ts +5 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +12 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp.d.ts +19 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +62 -0
- package/dist/mcp.js.map +1 -0
- package/dist/program.d.ts +6 -0
- package/dist/program.d.ts.map +1 -0
- package/dist/program.js +37 -0
- package/dist/program.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +2 -0
- package/dist/version.js.map +1 -0
- package/docs/commands.md +227 -0
- package/docs/configuration.md +18 -0
- package/docs/dev/DESIGN.md +13 -0
- package/docs/dev/TESTING.md +16 -0
- package/docs/dev/design-questions.md +24 -0
- package/docs/dev/reference-clis.md +15 -0
- package/docs/dev/skills/design-interview/SKILL.md +34 -0
- package/docs/dev/skills/release/SKILL.md +24 -0
- package/docs/dev/test-matrix.md +52 -0
- package/docs/index.md +7 -0
- package/docs/installation.md +17 -0
- package/docs/meta.json +18 -0
- package/docs/security.md +8 -0
- package/docs/troubleshooting.md +14 -0
- package/docs/usage.md +31 -0
- package/gitignore +44 -0
- package/kit/README.md +127 -0
- package/kit/dist/interview.js +170 -0
- package/kit/dist/main.js +70 -0
- package/kit/dist/manifest.js +111 -0
- package/kit/dist/new.js +256 -0
- package/kit/dist/rename.js +36 -0
- package/kit/dist/source.js +98 -0
- package/kit/dist/upgrade.js +230 -0
- package/kit/tsconfig.json +17 -0
- package/kit/tsconfig.test.json +5 -0
- package/lefthook.yml +2 -0
- package/lint/baseline/.gitkeep +0 -0
- package/lint/overrides.json +1 -0
- package/package.json +106 -4
- package/pnpm-workspace.yaml +6 -0
- package/scripts/audit.sh +22 -0
- package/scripts/command-program.ts +1 -0
- package/scripts/commands.ts +39 -0
- package/scripts/smoke.ts +56 -0
- package/scripts/test-matrix-untested.ts +12 -0
- package/skills/app-cli/SKILL.md +21 -0
- package/src/bin/app.ts +13 -0
- package/src/commands/doctor.ts +45 -0
- package/src/commands/mcp.ts +38 -0
- package/src/config.ts +4 -0
- package/src/identity.ts +15 -0
- package/src/index.ts +2 -0
- package/src/mcp.test.ts +84 -0
- package/src/mcp.ts +82 -0
- package/src/program.test.ts +66 -0
- package/src/program.ts +53 -0
- package/src/roots.test.ts +80 -0
- package/src/testing/argv-log.ts +1 -0
- package/src/testing/sandbox.ts +25 -0
- package/src/version.ts +1 -0
- package/template.json +40 -0
- package/tsconfig.json +9 -0
- package/tsconfig.scripts.json +1 -0
- package/tsconfig.test.json +1 -0
- package/vitest.config.ts +19 -0
package/kit/README.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# kit — `new` and `upgrade`
|
|
2
|
+
|
|
3
|
+
The copy tool behind `npx @wirecat/cli-template`. It is the package's `cli-template` bin
|
|
4
|
+
(`kit/dist/main.js`, built from `kit/src` by `tsc -p kit`). It is never copied into a new CLI.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
npx @wirecat/cli-template new cal-cli --command cal --package @wirecat/cal-cli --owner WireCatLabs \
|
|
8
|
+
--category notes --content-lang en,ru --sign-in token --api official-docs --filters time,person \
|
|
9
|
+
--never-store tokens --untrusted messages,links
|
|
10
|
+
npx @wirecat/cli-template upgrade --dry-run
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## `new <name>`
|
|
14
|
+
|
|
15
|
+
`new` copies the files of its own package version into `./<name>`. It renames the placeholders, rewrites
|
|
16
|
+
`package.json`, runs `git init` and writes `template.json`, `docs/dev/DESIGN.md` and the reference CLI into
|
|
17
|
+
`AGENTS.md`. It then prints the four steps that need a person. It does not install packages, commit or push.
|
|
18
|
+
|
|
19
|
+
In a terminal it asks for every missing answer. Without a terminal, a missing answer is an error that lists the
|
|
20
|
+
choices. Nothing is guessed.
|
|
21
|
+
|
|
22
|
+
| Flag | Answers (several are comma-separated) |
|
|
23
|
+
|---|---|
|
|
24
|
+
| `<name>` | the folder and repository name |
|
|
25
|
+
| `--command` | the command word |
|
|
26
|
+
| `--package` | the npm package name |
|
|
27
|
+
| `--owner` | the GitHub owner |
|
|
28
|
+
| `--category` | `messenger`, `mail`, `notes`, `files`, `calendar`, `meetings` |
|
|
29
|
+
| `--content-lang` | language codes, several: `en,ru` |
|
|
30
|
+
| `--sign-in` | `oauth`, `token`, `session`, `none` |
|
|
31
|
+
| `--api` | `official-client`, `official-docs`, `unofficial` |
|
|
32
|
+
| `--filters` | several of `time`, `person`, `container`, `text`, `status` |
|
|
33
|
+
| `--never-store` | several of `tokens`, `codes`, `secret-links`, `content` |
|
|
34
|
+
| `--untrusted` | several of `messages`, `files`, `links`, `names`, or `none` alone |
|
|
35
|
+
|
|
36
|
+
Every question except the names and `--content-lang` also takes `other:<your answer>`.
|
|
37
|
+
|
|
38
|
+
The content languages go only into `DESIGN.md`. The file also holds the speech-model order a messenger definition
|
|
39
|
+
should use, and the warnings for languages that have no stemmer or speech model in cli-core. `new` writes no search
|
|
40
|
+
or model setting.
|
|
41
|
+
|
|
42
|
+
## `upgrade`
|
|
43
|
+
|
|
44
|
+
Run it in a CLI made from the template, with a clean git working tree. For each template-owned file that the
|
|
45
|
+
template changed since the recorded version, it asks one of four things:
|
|
46
|
+
|
|
47
|
+
- **apply** merges the change with `git merge-file`. The base is the recorded version, downloaded with `npm pack`.
|
|
48
|
+
"Ours" is the CLI's file and "theirs" is this version. Both template versions are renamed first, so the command
|
|
49
|
+
word never conflicts.
|
|
50
|
+
- **replace** takes the template's file as it is.
|
|
51
|
+
- **skip** keeps the CLI's file. It is asked again only when the template changes that file again.
|
|
52
|
+
- **diff** shows the template's change.
|
|
53
|
+
|
|
54
|
+
Without a terminal, `--apply`, `--replace` or `--skip` sets the answer for every file. `--dry-run` lists what each
|
|
55
|
+
file would get and writes nothing. Seeds and `package.json` are never touched.
|
|
56
|
+
|
|
57
|
+
A conflict leaves git markers, and `template.json` keeps the new state under `pending`. Resolve the markers and run
|
|
58
|
+
`upgrade` again: it checks that the markers are gone and then moves to the new version.
|
|
59
|
+
|
|
60
|
+
## `template.json` in this repository
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{
|
|
64
|
+
"files": [".github/", "bin/release", "AGENTS.md", "docs/meta.json"],
|
|
65
|
+
"seeds": ["src/", "README.md", "docs/", "config/wirecat/"],
|
|
66
|
+
"placeholders": {
|
|
67
|
+
"command": "app", "name": "app-cli", "package": "@wirecat/app-cli", "repository": "WireCatLabs/app-cli"
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
- `files` lists the template-owned files that `upgrade` replays. `seeds` lists the files copied once and never
|
|
73
|
+
touched again; `config/wirecat/` goes there, because community's installer owns it. An entry ending in `/` is a
|
|
74
|
+
folder. The most specific entry wins: an exact path beats a folder, a deeper folder beats a shallower one, and a
|
|
75
|
+
seed wins a tie. Only listed files are copied. A listed file missing from the package stops `new`.
|
|
76
|
+
- `template.json` and `kit/` are never copied, whatever the lists say. The kit's tests run inside `pnpm test`, so
|
|
77
|
+
the configs name `kit/`: `new` and `upgrade` drop every `"kit/…"` entry from `vitest.config.ts`, `biome.json`,
|
|
78
|
+
`cspell.json`, `tsconfig*.json` and `.markdownlint-cli2.jsonc`. Do not add a kit-only workflow: `.github/` is
|
|
79
|
+
template-owned and would carry it into every new CLI.
|
|
80
|
+
- `README.md` and `CHANGELOG.md` describe the template, so `new` writes short ones for the new CLI.
|
|
81
|
+
- `docs/dev/DESIGN.md` is always a seed: it holds the CLI's interview answers.
|
|
82
|
+
- `package.json` is always a seed. `new` rewrites it: `name` becomes `--package`, `version` becomes `0.0.0`, and
|
|
83
|
+
`repository`, `homepage` and `bugs` point at `<owner>/<name>`. It also drops every `bin`, `files` entry and script
|
|
84
|
+
that points into `kit/`, and the `&& pnpm kit:…` parts of other scripts. `src/version.ts` gets `VERSION = "0.0.0"`.
|
|
85
|
+
- `placeholders` is optional; the defaults are shown above. The command word is replaced as a whole word, so `app`
|
|
86
|
+
changes but `application` does not. Its upper-case form changes too: `APP_HOME` becomes `CAL_HOME`. Followed by a
|
|
87
|
+
colon and a space it is a key and stays, so cli-core's `app: APP` becomes `app: CAL`. `name` (`app-cli`) is the repository
|
|
88
|
+
name: `skills/app-cli/` becomes `skills/cal-cli/`. The word `app` cannot appear in prose for any other meaning.
|
|
89
|
+
Paths are renamed the same way: `src/bin/app.ts` becomes `src/bin/cal.ts`.
|
|
90
|
+
- The version is `package.json`'s, never a second copy.
|
|
91
|
+
- `AGENTS.md` can hold the block that `new` fills with the reference CLI:
|
|
92
|
+
|
|
93
|
+
```md
|
|
94
|
+
<!-- cli-template:reference-cli:start -->
|
|
95
|
+
<!-- cli-template:reference-cli:end -->
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Without the block, `new` appends one.
|
|
99
|
+
|
|
100
|
+
## `template.json` in a CLI made from it
|
|
101
|
+
|
|
102
|
+
`new` writes this shape and `upgrade` keeps it current:
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{
|
|
106
|
+
"template": "@wirecat/cli-template",
|
|
107
|
+
"version": "0.1.0",
|
|
108
|
+
"names": { "name": "cal-cli", "command": "cal", "package": "@wirecat/cal-cli", "owner": "WireCatLabs" },
|
|
109
|
+
"files": { ".github/workflows/ci.yml": "<sha256 of the renamed template file at this version>" },
|
|
110
|
+
"seeds": ["src/", "README.md", "docs/", "config/wirecat/"]
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## What the package needs from the repository
|
|
115
|
+
|
|
116
|
+
- `"bin": { "cli-template": "kit/dist/main.js" }`, and `kit/dist` in `files`. No ignore file inside `kit/`: npm
|
|
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
|
+
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` 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`.
|
|
127
|
+
- `kit/test/**/*.test.ts` in vitest's `include`; `kit/dist` is ignored by the root `**/dist` rules of Biome and git.
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
const npmName = /^(@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/;
|
|
2
|
+
const commandWord = /^[a-z][a-z0-9-]*$/;
|
|
3
|
+
const githubOwner = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/;
|
|
4
|
+
const languageCode = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
|
|
5
|
+
export const NAME_QUESTIONS = [
|
|
6
|
+
{
|
|
7
|
+
key: "command",
|
|
8
|
+
flag: "command",
|
|
9
|
+
text: "The command word people type (lowercase, e.g. cal)",
|
|
10
|
+
check: (value) => commandWord.test(value) ? undefined : "lowercase letters, digits and dashes, starting with a letter",
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
key: "package",
|
|
14
|
+
flag: "package",
|
|
15
|
+
text: "The npm package name (e.g. @wirecat/cal-cli)",
|
|
16
|
+
check: (value) => (npmName.test(value) ? undefined : "a valid npm package name, scoped or not"),
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
key: "owner",
|
|
20
|
+
flag: "owner",
|
|
21
|
+
text: "The GitHub owner of the repository (e.g. WireCatLabs)",
|
|
22
|
+
check: (value) => (githubOwner.test(value) ? undefined : "a GitHub user or organisation name"),
|
|
23
|
+
},
|
|
24
|
+
];
|
|
25
|
+
export const INTERVIEW = [
|
|
26
|
+
{
|
|
27
|
+
key: "category",
|
|
28
|
+
title: "Category",
|
|
29
|
+
flag: "category",
|
|
30
|
+
text: "What kind of tool is it?",
|
|
31
|
+
choices: [
|
|
32
|
+
{ value: "messenger", label: "messenger" },
|
|
33
|
+
{ value: "mail", label: "mail" },
|
|
34
|
+
{ value: "notes", label: "notes" },
|
|
35
|
+
{ value: "files", label: "files and documents" },
|
|
36
|
+
{ value: "calendar", label: "calendar" },
|
|
37
|
+
{ value: "meetings", label: "meetings" },
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
key: "contentLang",
|
|
42
|
+
title: "Content languages",
|
|
43
|
+
flag: "content-lang",
|
|
44
|
+
text: "Which languages is the content in? Language codes, several allowed (e.g. en,ru)",
|
|
45
|
+
multiple: true,
|
|
46
|
+
check: (value) => (languageCode.test(value) ? undefined : "a language code such as en, ru or pt-BR"),
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
key: "signIn",
|
|
50
|
+
title: "Sign-in",
|
|
51
|
+
flag: "sign-in",
|
|
52
|
+
text: "How does a person sign in?",
|
|
53
|
+
choices: [
|
|
54
|
+
{ value: "oauth", label: "OAuth in the browser" },
|
|
55
|
+
{ value: "token", label: "an API token or key" },
|
|
56
|
+
{ value: "session", label: "a session (phone code, QR)" },
|
|
57
|
+
{ value: "none", label: "none" },
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
key: "api",
|
|
62
|
+
title: "The API",
|
|
63
|
+
flag: "api",
|
|
64
|
+
text: "What does the service offer to build on?",
|
|
65
|
+
choices: [
|
|
66
|
+
{ value: "official-client", label: "an official API with an official client library" },
|
|
67
|
+
{ value: "official-docs", label: "official API documentation only" },
|
|
68
|
+
{ value: "unofficial", label: "nothing official" },
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
key: "filters",
|
|
73
|
+
title: "How people filter",
|
|
74
|
+
flag: "filters",
|
|
75
|
+
text: "How do people filter what they find? Several allowed",
|
|
76
|
+
multiple: true,
|
|
77
|
+
choices: [
|
|
78
|
+
{ value: "time", label: "by time" },
|
|
79
|
+
{ value: "person", label: "by person" },
|
|
80
|
+
{ value: "container", label: "by container (chat, folder, calendar)" },
|
|
81
|
+
{ value: "text", label: "by words in the text" },
|
|
82
|
+
{ value: "status", label: "by status or label" },
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
key: "neverStore",
|
|
87
|
+
title: "Never in the store, a log or the output",
|
|
88
|
+
flag: "never-store",
|
|
89
|
+
text: "What must never reach the store, a log or the output? Several allowed",
|
|
90
|
+
multiple: true,
|
|
91
|
+
choices: [
|
|
92
|
+
{ value: "tokens", label: "tokens and passwords" },
|
|
93
|
+
{ value: "codes", label: "one-time codes and passcodes" },
|
|
94
|
+
{ value: "secret-links", label: "links that carry a secret" },
|
|
95
|
+
{ value: "content", label: "the full content, keep only metadata" },
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
key: "untrusted",
|
|
100
|
+
title: "Untrusted input",
|
|
101
|
+
flag: "untrusted",
|
|
102
|
+
text: "What untrusted input arrives? Several allowed",
|
|
103
|
+
multiple: true,
|
|
104
|
+
exclusive: "none",
|
|
105
|
+
choices: [
|
|
106
|
+
{ value: "messages", label: "messages from other people" },
|
|
107
|
+
{ value: "files", label: "files and attachments" },
|
|
108
|
+
{ value: "links", label: "links" },
|
|
109
|
+
{ value: "names", label: "names and titles set by others" },
|
|
110
|
+
{ value: "none", label: "none" },
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
];
|
|
114
|
+
const OTHER = "other:";
|
|
115
|
+
export const choiceList = (question) => question.choices
|
|
116
|
+
? `${question.choices.map((choice) => choice.value).join(", ")}, or ${OTHER}<your answer>`
|
|
117
|
+
: (question.check?.("") ?? "");
|
|
118
|
+
const checkOne = (question, value) => {
|
|
119
|
+
if (value.length === 0)
|
|
120
|
+
return "empty answer";
|
|
121
|
+
if (!question.choices)
|
|
122
|
+
return question.check?.(value);
|
|
123
|
+
if (value.startsWith(OTHER))
|
|
124
|
+
return value.length > OTHER.length ? undefined : `write the answer after "${OTHER}"`;
|
|
125
|
+
return question.choices.some((choice) => choice.value === value) ? undefined : `"${value}" is not one of the choices`;
|
|
126
|
+
};
|
|
127
|
+
export const parseAnswer = (question, raw) => {
|
|
128
|
+
const parts = question.multiple ? raw.split(",").map((part) => part.trim()) : [raw.trim()];
|
|
129
|
+
for (const part of parts) {
|
|
130
|
+
const problem = checkOne(question, part);
|
|
131
|
+
if (problem)
|
|
132
|
+
return { error: `--${question.flag}: ${problem}; expected ${choiceList(question)}` };
|
|
133
|
+
}
|
|
134
|
+
if (question.exclusive && parts.length > 1 && parts.includes(question.exclusive)) {
|
|
135
|
+
return { error: `--${question.flag}: "${question.exclusive}" cannot be combined with other answers` };
|
|
136
|
+
}
|
|
137
|
+
return { value: [...new Set(parts)] };
|
|
138
|
+
};
|
|
139
|
+
export const labelOf = (question, value) => value.startsWith(OTHER)
|
|
140
|
+
? `other: ${value.slice(OTHER.length)}`
|
|
141
|
+
: (question.choices?.find((choice) => choice.value === value)?.label ?? value);
|
|
142
|
+
const menu = (question) => [
|
|
143
|
+
...(question.choices ?? []).map((choice, index) => ` ${index + 1}. ${choice.label}`),
|
|
144
|
+
` ${(question.choices?.length ?? 0) + 1}. other (type it)`,
|
|
145
|
+
].join("\n");
|
|
146
|
+
const fromMenu = async (rl, question, line) => {
|
|
147
|
+
const choices = question.choices ?? [];
|
|
148
|
+
const picked = [];
|
|
149
|
+
for (const part of line.split(",")) {
|
|
150
|
+
const index = Number(part.trim()) - 1;
|
|
151
|
+
if (index === choices.length)
|
|
152
|
+
picked.push(`${OTHER}${(await rl.question(" Other: ")).trim()}`);
|
|
153
|
+
else
|
|
154
|
+
picked.push(choices[index]?.value ?? part.trim());
|
|
155
|
+
}
|
|
156
|
+
return picked.join(",");
|
|
157
|
+
};
|
|
158
|
+
export const ask = async (rl, output, question) => {
|
|
159
|
+
for (;;) {
|
|
160
|
+
const prompt = question.choices
|
|
161
|
+
? `${question.text}\n${menu(question)}\n${question.multiple ? "Numbers, comma-separated: " : "Number: "}`
|
|
162
|
+
: `${question.text}: `;
|
|
163
|
+
const line = (await rl.question(prompt)).trim();
|
|
164
|
+
const raw = question.choices && /^[\d,\s]+$/.test(line) ? await fromMenu(rl, question, line) : line;
|
|
165
|
+
const { value, error } = parseAnswer(question, raw);
|
|
166
|
+
if (value)
|
|
167
|
+
return value;
|
|
168
|
+
output.write(`${error}\n`);
|
|
169
|
+
}
|
|
170
|
+
};
|
package/kit/dist/main.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { INTERVIEW, NAME_QUESTIONS } from "./interview.js";
|
|
4
|
+
import { runNew } from "./new.js";
|
|
5
|
+
import { fetchPublished, loadSource, ownRoot } from "./source.js";
|
|
6
|
+
import { parseMode, runUpgrade } from "./upgrade.js";
|
|
7
|
+
const USAGE = `Usage:
|
|
8
|
+
npx @wirecat/cli-template new <name> [answers] copy the template into ./<name>
|
|
9
|
+
npx @wirecat/cli-template upgrade [--apply | --replace | --skip] [--dry-run]
|
|
10
|
+
|
|
11
|
+
new asks for every missing answer in a terminal; without one, a missing answer is an error.
|
|
12
|
+
--command <word> the command word people type
|
|
13
|
+
--package <name> the npm package name
|
|
14
|
+
--owner <owner> the GitHub owner of the repository
|
|
15
|
+
${INTERVIEW.map((question) => ` --${question.flag.padEnd(20)}${question.title ?? question.text}`).join("\n")}
|
|
16
|
+
Any interview answer also takes "other:<your answer>"; several answers are comma-separated.
|
|
17
|
+
|
|
18
|
+
upgrade brings template changes into a CLI made from it; it needs a clean git working tree.
|
|
19
|
+
--apply merge the template's change into every changed file (conflicts get git markers)
|
|
20
|
+
--replace take the template's new file as it is
|
|
21
|
+
--skip keep this CLI's files
|
|
22
|
+
--dry-run list the files and what each would get; write nothing
|
|
23
|
+
`;
|
|
24
|
+
const answerFlags = Object.fromEntries([...NAME_QUESTIONS, ...INTERVIEW].map((question) => [question.flag, { type: "string" }]));
|
|
25
|
+
const main = async (argv, io) => {
|
|
26
|
+
const [command, ...rest] = argv;
|
|
27
|
+
if (!command || command === "--help" || command === "-h")
|
|
28
|
+
return USAGE;
|
|
29
|
+
const source = await loadSource(ownRoot());
|
|
30
|
+
if (command === "--version")
|
|
31
|
+
return source.version;
|
|
32
|
+
if (command === "new") {
|
|
33
|
+
const { values, positionals } = parseArgs({
|
|
34
|
+
args: rest,
|
|
35
|
+
options: answerFlags,
|
|
36
|
+
allowPositionals: true,
|
|
37
|
+
strict: true,
|
|
38
|
+
});
|
|
39
|
+
if (positionals.length > 1)
|
|
40
|
+
throw new Error(`new takes one folder name, got ${positionals.join(" ")}`);
|
|
41
|
+
return runNew(process.cwd(), source, { name: positionals[0], flags: values }, io);
|
|
42
|
+
}
|
|
43
|
+
if (command === "upgrade") {
|
|
44
|
+
const { values, positionals } = parseArgs({
|
|
45
|
+
args: rest,
|
|
46
|
+
options: {
|
|
47
|
+
apply: { type: "boolean" },
|
|
48
|
+
replace: { type: "boolean" },
|
|
49
|
+
skip: { type: "boolean" },
|
|
50
|
+
"dry-run": { type: "boolean" },
|
|
51
|
+
},
|
|
52
|
+
strict: true,
|
|
53
|
+
});
|
|
54
|
+
if (positionals.length)
|
|
55
|
+
throw new Error(`upgrade takes no arguments, got ${positionals.join(" ")}`);
|
|
56
|
+
return runUpgrade(process.cwd(), source, fetchPublished, { mode: parseMode(values), dryRun: values["dry-run"] ?? false }, io);
|
|
57
|
+
}
|
|
58
|
+
throw new Error(`unknown command "${command}"; expected new or upgrade\n\n${USAGE}`);
|
|
59
|
+
};
|
|
60
|
+
const io = {
|
|
61
|
+
input: process.stdin,
|
|
62
|
+
output: process.stdout,
|
|
63
|
+
interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
64
|
+
};
|
|
65
|
+
main(process.argv.slice(2), io).then((text) => {
|
|
66
|
+
process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
|
|
67
|
+
}, (error) => {
|
|
68
|
+
process.stderr.write(`cli-template: ${error.message}\n`);
|
|
69
|
+
process.exitCode = 1;
|
|
70
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
export const MANIFEST = "template.json";
|
|
5
|
+
export const DEFAULT_PLACEHOLDERS = {
|
|
6
|
+
command: "app",
|
|
7
|
+
name: "app-cli",
|
|
8
|
+
package: "@wirecat/app-cli",
|
|
9
|
+
repository: "WireCatLabs/app-cli",
|
|
10
|
+
};
|
|
11
|
+
// package.json and the interview's answers belong to the CLI from the first copy.
|
|
12
|
+
const ALWAYS_SEEDS = ["package.json", "docs/dev/DESIGN.md"];
|
|
13
|
+
// The copy tool and the template's own manifest are never part of a CLI, whatever template.json lists.
|
|
14
|
+
const NEVER_COPIED = [MANIFEST, "kit/"];
|
|
15
|
+
export const sha256 = (content) => createHash("sha256").update(content).digest("hex");
|
|
16
|
+
const fail = (file, problem) => {
|
|
17
|
+
throw new Error(`${file}: ${problem}`);
|
|
18
|
+
};
|
|
19
|
+
const isStringList = (value) => Array.isArray(value) && value.every((item) => typeof item === "string" && item.length > 0);
|
|
20
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
21
|
+
const checkEntries = (file, key, entries) => {
|
|
22
|
+
for (const entry of entries) {
|
|
23
|
+
if (entry.startsWith("/") || entry.split("/").includes(".."))
|
|
24
|
+
fail(file, `"${key}" entry "${entry}" must be relative`);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
export const parseTemplateManifest = (file, raw) => {
|
|
28
|
+
if (!isRecord(raw))
|
|
29
|
+
return fail(file, "must be a JSON object");
|
|
30
|
+
if (!isStringList(raw.files))
|
|
31
|
+
return fail(file, `"files" must be a list of paths`);
|
|
32
|
+
if (!isStringList(raw.seeds))
|
|
33
|
+
return fail(file, `"seeds" must be a list of paths`);
|
|
34
|
+
checkEntries(file, "files", raw.files);
|
|
35
|
+
checkEntries(file, "seeds", raw.seeds);
|
|
36
|
+
const placeholders = raw.placeholders ?? {};
|
|
37
|
+
if (!isRecord(placeholders))
|
|
38
|
+
return fail(file, `"placeholders" must be an object`);
|
|
39
|
+
for (const [key, value] of Object.entries(placeholders)) {
|
|
40
|
+
if (!(key in DEFAULT_PLACEHOLDERS))
|
|
41
|
+
fail(file, `unknown placeholder "${key}"`);
|
|
42
|
+
if (typeof value !== "string" || value.length === 0)
|
|
43
|
+
fail(file, `placeholder "${key}" must be a string`);
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
files: raw.files,
|
|
47
|
+
seeds: raw.seeds,
|
|
48
|
+
placeholders: { ...DEFAULT_PLACEHOLDERS, ...placeholders },
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
export const parseCliManifest = (file, raw) => {
|
|
52
|
+
if (!isRecord(raw))
|
|
53
|
+
return fail(file, "must be a JSON object");
|
|
54
|
+
const { template, version, names, files, seeds, pending } = raw;
|
|
55
|
+
if (typeof template !== "string")
|
|
56
|
+
return fail(file, `"template" must be the template's package name`);
|
|
57
|
+
if (typeof version !== "string")
|
|
58
|
+
return fail(file, `"version" must be the template version this CLI is on`);
|
|
59
|
+
if (!isRecord(names) || !["name", "command", "package", "owner"].every((key) => typeof names[key] === "string")) {
|
|
60
|
+
return fail(file, `"names" must hold name, command, package and owner`);
|
|
61
|
+
}
|
|
62
|
+
if (!isRecord(files) || !Object.values(files).every((hash) => typeof hash === "string")) {
|
|
63
|
+
return fail(file, `"files" must map each template-owned path to its sha256`);
|
|
64
|
+
}
|
|
65
|
+
if (!isStringList(seeds))
|
|
66
|
+
return fail(file, `"seeds" must be a list of paths`);
|
|
67
|
+
if (pending !== undefined &&
|
|
68
|
+
(!isRecord(pending) ||
|
|
69
|
+
typeof pending.version !== "string" ||
|
|
70
|
+
!isRecord(pending.files) ||
|
|
71
|
+
!isStringList(pending.conflicts))) {
|
|
72
|
+
return fail(file, `"pending" must hold version, files and conflicts`);
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
template,
|
|
76
|
+
version,
|
|
77
|
+
names: names,
|
|
78
|
+
files: files,
|
|
79
|
+
seeds,
|
|
80
|
+
...(pending ? { pending: pending } : {}),
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
export const readJson = async (path) => {
|
|
84
|
+
const text = await readFile(path, "utf8").catch(() => fail(path, "not found"));
|
|
85
|
+
try {
|
|
86
|
+
return JSON.parse(text);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return fail(path, "is not valid JSON");
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
export const readCliManifest = async (dir) => {
|
|
93
|
+
const path = join(dir, MANIFEST);
|
|
94
|
+
return parseCliManifest(path, await readJson(path));
|
|
95
|
+
};
|
|
96
|
+
const matches = (entry, path) => entry.endsWith("/") ? path.startsWith(entry) : path === entry;
|
|
97
|
+
// An exact path beats a folder, and a deeper folder beats a shallower one, so a seed folder can hold
|
|
98
|
+
// one template-owned file and the other way round.
|
|
99
|
+
const specificity = (entry) => (entry.endsWith("/") ? entry.length : Number.MAX_SAFE_INTEGER);
|
|
100
|
+
export const classify = (path, files, seeds) => {
|
|
101
|
+
if (NEVER_COPIED.some((entry) => matches(entry, path)))
|
|
102
|
+
return undefined;
|
|
103
|
+
if (ALWAYS_SEEDS.includes(path))
|
|
104
|
+
return "seed";
|
|
105
|
+
const best = (entries) => entries.reduce((top, entry) => (matches(entry, path) ? Math.max(top, specificity(entry)) : top), -1);
|
|
106
|
+
const file = best(files);
|
|
107
|
+
const seed = best(seeds);
|
|
108
|
+
if (file < 0 && seed < 0)
|
|
109
|
+
return undefined;
|
|
110
|
+
return seed >= file ? "seed" : "file";
|
|
111
|
+
};
|