@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/dist/new.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { chmod, mkdir, readdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { ask, choiceList, INTERVIEW, labelOf, NAME_QUESTIONS, parseAnswer } from "./interview.js";
|
|
7
|
+
import { MANIFEST, sha256 } from "./manifest.js";
|
|
8
|
+
import { PACKED_AS, renderTemplate } from "./source.js";
|
|
9
|
+
const run = promisify(execFile);
|
|
10
|
+
const REFERENCES = {
|
|
11
|
+
messenger: ["whatsapp-cli", "tg-cli", "max-cli"],
|
|
12
|
+
files: ["drive-cli"],
|
|
13
|
+
meetings: ["zoom-cli"],
|
|
14
|
+
notes: ["cli-memo"],
|
|
15
|
+
};
|
|
16
|
+
const STEMMED = ["en", "ru", "es"];
|
|
17
|
+
// Parakeet v3's languages, from cli-core's speech model list.
|
|
18
|
+
const PARAKEET = "bg cs da de el en es et fi fr hr hu it lt lv mt nl pl pt ro ru sk sl sv uk".split(" ");
|
|
19
|
+
const baseLang = (code) => code.split("-")[0] ?? code;
|
|
20
|
+
export const speechModels = (languages) => {
|
|
21
|
+
const bases = languages.map(baseLang);
|
|
22
|
+
if (bases.length > 0 && bases.every((lang) => lang === "ru"))
|
|
23
|
+
return ["gigaam-v3", "gigaam-v3-ctc", "parakeet-v3"];
|
|
24
|
+
if (bases.length > 0 && bases.every((lang) => PARAKEET.includes(lang)))
|
|
25
|
+
return ["parakeet-v3"];
|
|
26
|
+
return undefined;
|
|
27
|
+
};
|
|
28
|
+
export const languageWarnings = (languages) => languages.flatMap((lang) => [
|
|
29
|
+
...(STEMMED.includes(baseLang(lang))
|
|
30
|
+
? []
|
|
31
|
+
: [`${lang}: cli-core has no search stemmer for it; search matches whole words only`]),
|
|
32
|
+
...(PARAKEET.includes(baseLang(lang)) ? [] : [`${lang}: no speech model in cli-core hears it`]),
|
|
33
|
+
]);
|
|
34
|
+
const NAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
|
|
35
|
+
const NAME_QUESTION = {
|
|
36
|
+
key: "name",
|
|
37
|
+
flag: "name",
|
|
38
|
+
text: "The folder (and repository) name, e.g. cal-cli",
|
|
39
|
+
check: (value) => (NAME_PATTERN.test(value) ? undefined : "lowercase letters, digits, dots and dashes"),
|
|
40
|
+
};
|
|
41
|
+
const collect = async (args, io) => {
|
|
42
|
+
const questions = [NAME_QUESTION, ...NAME_QUESTIONS, ...INTERVIEW];
|
|
43
|
+
const given = { ...args.flags, name: args.name };
|
|
44
|
+
const answers = {};
|
|
45
|
+
const missing = [];
|
|
46
|
+
for (const question of questions) {
|
|
47
|
+
const raw = given[question.flag];
|
|
48
|
+
if (raw === undefined) {
|
|
49
|
+
missing.push(question);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const { value, error } = parseAnswer(question, raw);
|
|
53
|
+
if (error)
|
|
54
|
+
throw new Error(question === NAME_QUESTION ? `<name>: ${error.replace(/^--name: /, "")}` : error);
|
|
55
|
+
answers[question.key] = value ?? [];
|
|
56
|
+
}
|
|
57
|
+
if (missing.length > 0 && !io.interactive) {
|
|
58
|
+
const lines = missing.map((question) => question === NAME_QUESTION ? " <name>: the folder to create" : ` --${question.flag}: ${choiceList(question)}`);
|
|
59
|
+
throw new Error(`no terminal to ask in, and these answers are missing:\n${lines.join("\n")}`);
|
|
60
|
+
}
|
|
61
|
+
if (missing.length > 0) {
|
|
62
|
+
const rl = createInterface({ input: io.input, output: io.output, terminal: false });
|
|
63
|
+
try {
|
|
64
|
+
for (const question of missing)
|
|
65
|
+
answers[question.key] = await ask(rl, io.output, question);
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
rl.close();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const one = (key) => answers[key]?.[0] ?? "";
|
|
72
|
+
return {
|
|
73
|
+
names: { name: one("name"), command: one("command"), package: one("package"), owner: one("owner") },
|
|
74
|
+
answers,
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
const isEmptyOrMissing = async (dir) => readdir(dir).then((entries) => entries.length === 0, () => true);
|
|
78
|
+
const referenceLine = (category) => {
|
|
79
|
+
const refs = REFERENCES[category];
|
|
80
|
+
if (!refs) {
|
|
81
|
+
return "No reference CLI for this category yet: pick the nearest one with the owner from the Reference CLIs page.";
|
|
82
|
+
}
|
|
83
|
+
const [first, ...rest] = refs;
|
|
84
|
+
const link = (repo) => `[${repo}](https://github.com/WireCatLabs/${repo})`;
|
|
85
|
+
return `Learn from ${link(first ?? "")}${rest.length ? `, then ${rest.map(link).join(" and ")}` : ""}.`;
|
|
86
|
+
};
|
|
87
|
+
const START = "<!-- cli-template:reference-cli:start -->";
|
|
88
|
+
const END = "<!-- cli-template:reference-cli:end -->";
|
|
89
|
+
export const withReference = (agents, category) => {
|
|
90
|
+
const block = `${START}\n## Reference CLI\n\n${referenceLine(category)}\n${END}`;
|
|
91
|
+
const start = agents.indexOf(START);
|
|
92
|
+
const end = agents.indexOf(END);
|
|
93
|
+
if (start >= 0 && end > start)
|
|
94
|
+
return agents.slice(0, start) + block + agents.slice(end + END.length);
|
|
95
|
+
return `${agents.replace(/\n*$/, "")}\n\n${block}\n`;
|
|
96
|
+
};
|
|
97
|
+
export const designDoc = (names, answers) => {
|
|
98
|
+
const rows = INTERVIEW.map((question) => `- **${question.title ?? question.text}:** ${(answers[question.key] ?? []).map((value) => labelOf(question, value)).join(", ")}`);
|
|
99
|
+
const languages = answers.contentLang ?? [];
|
|
100
|
+
const speech = speechModels(languages);
|
|
101
|
+
const warnings = languageWarnings(languages);
|
|
102
|
+
return [
|
|
103
|
+
`# ${names.command} — design`,
|
|
104
|
+
"",
|
|
105
|
+
`Written by \`npx @wirecat/cli-template new\`. The agent reads it first, asks the further questions the reference CLI`,
|
|
106
|
+
"does not answer, and starts building only after the owner has read it.",
|
|
107
|
+
"",
|
|
108
|
+
"## Answers",
|
|
109
|
+
"",
|
|
110
|
+
...rows,
|
|
111
|
+
"",
|
|
112
|
+
"## Reference CLI",
|
|
113
|
+
"",
|
|
114
|
+
referenceLine(answers.category?.[0] ?? ""),
|
|
115
|
+
"",
|
|
116
|
+
"## Content languages",
|
|
117
|
+
"",
|
|
118
|
+
`Languages: ${languages.join(", ")}.`,
|
|
119
|
+
speech
|
|
120
|
+
? `If this CLI defines a messenger, set its \`speechModels\` to ${speech.map((model) => `\`${model}\``).join(", ")}.`
|
|
121
|
+
: "No speech model in cli-core covers all of these languages: leave `speechModels` out.",
|
|
122
|
+
...warnings.map((warning) => `- ${warning}`),
|
|
123
|
+
"",
|
|
124
|
+
"## Further answers",
|
|
125
|
+
"",
|
|
126
|
+
"## Open questions for the owner",
|
|
127
|
+
"",
|
|
128
|
+
].join("\n");
|
|
129
|
+
};
|
|
130
|
+
const removeKit = (command) => command
|
|
131
|
+
.split(" && ")
|
|
132
|
+
.filter((part) => !/\bkit[:/]/.test(part))
|
|
133
|
+
.join(" && ");
|
|
134
|
+
export const cliPackageJson = (text, names) => {
|
|
135
|
+
const pkg = JSON.parse(text);
|
|
136
|
+
const isKit = (value) => typeof value === "string" && /^(\.\/)?kit(\/|$)/.test(value);
|
|
137
|
+
const repoUrl = `git+https://github.com/${names.owner}/${names.name}.git`;
|
|
138
|
+
const next = { ...pkg, name: names.package, version: "0.0.0" };
|
|
139
|
+
if (typeof pkg.bin === "object" && pkg.bin) {
|
|
140
|
+
next.bin = Object.fromEntries(Object.entries(pkg.bin).filter(([, value]) => !isKit(value)));
|
|
141
|
+
}
|
|
142
|
+
if (Array.isArray(pkg.files))
|
|
143
|
+
next.files = pkg.files.filter((entry) => !isKit(entry) && !Object.values(PACKED_AS).includes(entry));
|
|
144
|
+
if (typeof pkg.scripts === "object" && pkg.scripts) {
|
|
145
|
+
next.scripts = Object.fromEntries(Object.entries(pkg.scripts)
|
|
146
|
+
.filter(([key]) => !key.startsWith("kit:"))
|
|
147
|
+
.map(([key, value]) => [key, removeKit(value)])
|
|
148
|
+
.filter(([, value]) => value.length > 0));
|
|
149
|
+
}
|
|
150
|
+
if (pkg.repository)
|
|
151
|
+
next.repository = { type: "git", url: repoUrl };
|
|
152
|
+
if (pkg.homepage)
|
|
153
|
+
next.homepage = `https://github.com/${names.owner}/${names.name}#readme`;
|
|
154
|
+
if (pkg.bugs)
|
|
155
|
+
next.bugs = { url: `https://github.com/${names.owner}/${names.name}/issues` };
|
|
156
|
+
return `${JSON.stringify(next, null, 2)}\n`;
|
|
157
|
+
};
|
|
158
|
+
const VERSION_FILE = "src/version.ts";
|
|
159
|
+
export const cliReadme = (names) => [
|
|
160
|
+
`# ${names.command}`,
|
|
161
|
+
"",
|
|
162
|
+
`\`${names.command}\` is a WireCat command-line tool, started from`,
|
|
163
|
+
"[cli-template](https://github.com/WireCatLabs/cli-template). Its design is in",
|
|
164
|
+
"[docs/dev/DESIGN.md](docs/dev/DESIGN.md).",
|
|
165
|
+
"",
|
|
166
|
+
"## Quick start",
|
|
167
|
+
"",
|
|
168
|
+
"```sh",
|
|
169
|
+
"pnpm install",
|
|
170
|
+
"pnpm build",
|
|
171
|
+
`node dist/bin/${names.command}.js personal doctor`,
|
|
172
|
+
"```",
|
|
173
|
+
"",
|
|
174
|
+
"`personal` is the profile: a name you choose for one account. Every command that uses an account starts with it.",
|
|
175
|
+
"",
|
|
176
|
+
].join("\n");
|
|
177
|
+
export const cliChangelog = (names, source) => [
|
|
178
|
+
"# Changelog",
|
|
179
|
+
"",
|
|
180
|
+
`Notable changes to \`${names.package}\`. One section per version, newest first.`,
|
|
181
|
+
"",
|
|
182
|
+
"## Unreleased",
|
|
183
|
+
"",
|
|
184
|
+
"### Added",
|
|
185
|
+
"",
|
|
186
|
+
`- Started from \`${source.name}\` ${source.version}.`,
|
|
187
|
+
"",
|
|
188
|
+
].join("\n");
|
|
189
|
+
const writeOut = async (dir, path, content, mode = 0o644) => {
|
|
190
|
+
const full = join(dir, path);
|
|
191
|
+
await mkdir(dirname(full), { recursive: true });
|
|
192
|
+
await writeFile(full, content);
|
|
193
|
+
await chmod(full, mode);
|
|
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
|
+
};
|
|
201
|
+
export const manualSteps = (names) => [
|
|
202
|
+
`1. Create the repository ${names.owner}/${names.name} and its main ruleset (cli-base docs/standards/new-cli.md).`,
|
|
203
|
+
`2. Set npm trusted publishing for ${names.package}, naming release.yml (WC-REL-11), and add any secrets.`,
|
|
204
|
+
"3. Pick a monthly cron day no other repository uses: .github/workflows/monthly.yml holds a placeholder CI rejects.",
|
|
205
|
+
"4. WireCat CLIs only: a pull request to cli-base's PARITY_GROUPS, a line in cli-core's WIRECAT_TOOLS and in cli-docs' tools.json.",
|
|
206
|
+
];
|
|
207
|
+
export const runNew = async (cwd, source, args, io) => {
|
|
208
|
+
const { names, answers } = await collect(args, io);
|
|
209
|
+
const target = join(cwd, names.name);
|
|
210
|
+
if (!(await isEmptyOrMissing(target)))
|
|
211
|
+
throw new Error(`${target} already exists and is not empty`);
|
|
212
|
+
const rendered = await renderTemplate(source, names);
|
|
213
|
+
const category = answers.category?.[0] ?? "";
|
|
214
|
+
const rewrites = {
|
|
215
|
+
"package.json": (text) => cliPackageJson(text, names),
|
|
216
|
+
"README.md": () => cliReadme(names),
|
|
217
|
+
"CHANGELOG.md": () => cliChangelog(names, source),
|
|
218
|
+
[VERSION_FILE]: (text) => text.replace(/VERSION = "[^"]*"/, 'VERSION = "0.0.0"'),
|
|
219
|
+
"AGENTS.md": (text) => withReference(text, category),
|
|
220
|
+
};
|
|
221
|
+
const hashes = {};
|
|
222
|
+
for (const [path, file] of rendered) {
|
|
223
|
+
if (file.kind === "file")
|
|
224
|
+
hashes[path] = sha256(file.content);
|
|
225
|
+
const rewrite = rewrites[path];
|
|
226
|
+
const content = rewrite ? rewrite(file.content.toString("utf8")) : file.content;
|
|
227
|
+
await writeOut(target, path, content, file.mode);
|
|
228
|
+
}
|
|
229
|
+
if (!rendered.has("AGENTS.md"))
|
|
230
|
+
await writeOut(target, "AGENTS.md", withReference("", category));
|
|
231
|
+
await writeOut(target, "docs/dev/DESIGN.md", designDoc(names, answers));
|
|
232
|
+
const manifest = {
|
|
233
|
+
template: source.name,
|
|
234
|
+
version: source.version,
|
|
235
|
+
names,
|
|
236
|
+
files: hashes,
|
|
237
|
+
seeds: source.manifest.seeds,
|
|
238
|
+
};
|
|
239
|
+
await writeOut(target, MANIFEST, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
240
|
+
await run("git", ["init", "-q", "-b", "main"], { cwd: target });
|
|
241
|
+
await markExecutable(target, [...rendered].filter(([, file]) => file.mode & 0o111).map(([path]) => path));
|
|
242
|
+
const warnings = languageWarnings(answers.contentLang ?? []);
|
|
243
|
+
return [
|
|
244
|
+
`Created ${target} from ${source.name}@${source.version}.`,
|
|
245
|
+
...(warnings.length ? ["", "Content languages:", ...warnings.map((warning) => ` ${warning}`)] : []),
|
|
246
|
+
"",
|
|
247
|
+
"Next:",
|
|
248
|
+
` cd ${names.name} && pnpm install && pnpm ci:quick`,
|
|
249
|
+
' git add -A && git commit -m "chore: start from @wirecat/cli-template"',
|
|
250
|
+
` then give the agent docs/dev/DESIGN.md; every example passes a profile, e.g. ${names.command} personal doctor`,
|
|
251
|
+
"",
|
|
252
|
+
"Four steps need a person:",
|
|
253
|
+
...manualSteps(names).map((step) => ` ${step}`),
|
|
254
|
+
"",
|
|
255
|
+
].join("\n");
|
|
256
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const escapeRegex = (text) => text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
|
2
|
+
const word = (text) => `(?<![A-Za-z0-9_])${escapeRegex(text)}(?![A-Za-z0-9])`;
|
|
3
|
+
export const upperCommand = (command) => command.toUpperCase().replaceAll("-", "_");
|
|
4
|
+
// The command word followed by ": " is a key, such as cli-core's `app: APP` in a program definition, and
|
|
5
|
+
// stays. One pass, so a replacement that itself contains the command placeholder (a package named
|
|
6
|
+
// "@x/app-tools") is never renamed a second time.
|
|
7
|
+
export const renamer = (placeholders, names) => {
|
|
8
|
+
const table = new Map([
|
|
9
|
+
[placeholders.package, names.package],
|
|
10
|
+
[placeholders.repository, `${names.owner}/${names.name}`],
|
|
11
|
+
[placeholders.name, names.name],
|
|
12
|
+
[placeholders.command, names.command],
|
|
13
|
+
[upperCommand(placeholders.command), upperCommand(names.command)],
|
|
14
|
+
]);
|
|
15
|
+
const pattern = new RegExp([escapeRegex(placeholders.package), escapeRegex(placeholders.repository), word(placeholders.name)]
|
|
16
|
+
.concat(`${word(placeholders.command)}(?!:\\s)`)
|
|
17
|
+
.concat(word(upperCommand(placeholders.command)))
|
|
18
|
+
.join("|"), "g");
|
|
19
|
+
return (text) => text.replace(pattern, (match) => table.get(match) ?? match);
|
|
20
|
+
};
|
|
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"));
|
|
23
|
+
export const renameContent = (rename, content) => isBinary(content) ? content : Buffer.from(rename(content.toString("utf8")), "utf8");
|
|
24
|
+
const KIT_CONFIG = /(^|\/)(vitest\.config\.ts|biome\.json|cspell\.json|tsconfig[^/]*\.json|\.markdownlint-cli2\.jsonc)$/;
|
|
25
|
+
// The template's configs name kit/ so its own checks cover the copy tool; a CLI has no kit/, and the
|
|
26
|
+
// entries would point at nothing (or fail, for an include that must match).
|
|
27
|
+
export const stripKit = (path, content) => {
|
|
28
|
+
if (!KIT_CONFIG.test(path))
|
|
29
|
+
return content;
|
|
30
|
+
const text = content.toString("utf8");
|
|
31
|
+
const stripped = text
|
|
32
|
+
.replace(/\[\s*"!?(\.\/)?kit\/[^"]*"\s*\]/g, "[]")
|
|
33
|
+
.replace(/,\s*"!?(\.\/)?kit\/[^"]*"/g, "")
|
|
34
|
+
.replace(/"!?(\.\/)?kit\/[^"]*",\s*/g, "");
|
|
35
|
+
return stripped === text ? content : Buffer.from(stripped, "utf8");
|
|
36
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdtemp, readdir, readFile, rm, stat } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join, relative, sep } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { classify, MANIFEST, parseTemplateManifest, readJson, } from "./manifest.js";
|
|
9
|
+
import { renameContent, renamer, stripKit } from "./rename.js";
|
|
10
|
+
const run = promisify(execFile);
|
|
11
|
+
export const ownRoot = () => fileURLToPath(new URL("../../", import.meta.url));
|
|
12
|
+
export const loadSource = async (root) => {
|
|
13
|
+
const pkg = (await readJson(join(root, "package.json")));
|
|
14
|
+
if (typeof pkg.name !== "string" || typeof pkg.version !== "string") {
|
|
15
|
+
throw new Error(`${join(root, "package.json")}: needs a name and a version`);
|
|
16
|
+
}
|
|
17
|
+
const manifestPath = join(root, MANIFEST);
|
|
18
|
+
const manifest = parseTemplateManifest(manifestPath, await readJson(manifestPath));
|
|
19
|
+
return { root, name: pkg.name, version: pkg.version, manifest };
|
|
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
|
+
};
|
|
31
|
+
const walk = async (dir) => {
|
|
32
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
33
|
+
const nested = await Promise.all(entries.map(async (entry) => {
|
|
34
|
+
const path = join(dir, entry.name);
|
|
35
|
+
if (entry.isDirectory())
|
|
36
|
+
return SKIP_DIRS.has(entry.name) ? [] : walk(path);
|
|
37
|
+
return entry.isFile() ? [path] : [];
|
|
38
|
+
}));
|
|
39
|
+
return nested.flat();
|
|
40
|
+
};
|
|
41
|
+
const listEntry = async (root, entry) => {
|
|
42
|
+
const path = await onDisk(root, entry);
|
|
43
|
+
const info = await stat(path).catch(() => undefined);
|
|
44
|
+
if (!info)
|
|
45
|
+
throw new Error(`${MANIFEST} lists "${entry}", but the package has no such ${entry.endsWith("/") ? "folder" : "file"}`);
|
|
46
|
+
if (!entry.endsWith("/"))
|
|
47
|
+
return [entry];
|
|
48
|
+
return (await walk(path)).map((file) => relative(root, file).split(sep).join("/"));
|
|
49
|
+
};
|
|
50
|
+
export const listTemplate = async (source) => {
|
|
51
|
+
const { files, seeds } = source.manifest;
|
|
52
|
+
const paths = (await Promise.all(["package.json", ...files, ...seeds].map((entry) => listEntry(source.root, entry)))).flat();
|
|
53
|
+
return new Map([...new Set(paths)].sort().flatMap((path) => {
|
|
54
|
+
const kind = classify(path, files, seeds);
|
|
55
|
+
return kind ? [[path, kind]] : [];
|
|
56
|
+
}));
|
|
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));
|
|
61
|
+
export const renderTemplate = async (source, names) => {
|
|
62
|
+
const rename = renamer(source.manifest.placeholders, names);
|
|
63
|
+
const listed = await listTemplate(source);
|
|
64
|
+
const rendered = await Promise.all([...listed].map(async ([path, kind]) => {
|
|
65
|
+
const full = await onDisk(source.root, path);
|
|
66
|
+
const [content, info] = await Promise.all([readFile(full), stat(full)]);
|
|
67
|
+
const renamed = renameContent(rename, stripKit(path, content));
|
|
68
|
+
const mode = isExecutable(path, content, info.mode) ? 0o755 : 0o644;
|
|
69
|
+
return [rename(path), { kind, content: renamed, mode }];
|
|
70
|
+
}));
|
|
71
|
+
return new Map(rendered);
|
|
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
|
+
};
|
|
82
|
+
export const fetchPublished = async (name, version) => {
|
|
83
|
+
const dir = await mkdtemp(join(tmpdir(), "cli-template-"));
|
|
84
|
+
const { stdout } = await run(...npmCommand(["pack", `${name}@${version}`, "--silent", "--pack-destination", dir])).catch((error) => {
|
|
85
|
+
throw new Error(`could not download ${name}@${version} from npm, which upgrade needs as the merge base: ${error.message}`);
|
|
86
|
+
});
|
|
87
|
+
const tarball = stdout.trim().split("\n").pop() ?? "";
|
|
88
|
+
const dispose = () => rm(dir, { recursive: true, force: true });
|
|
89
|
+
try {
|
|
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 });
|
|
92
|
+
return { source: await loadSource(join(dir, "package")), dispose };
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
await dispose();
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { classify, MANIFEST, readCliManifest, sha256 } from "./manifest.js";
|
|
8
|
+
import { markExecutable } from "./new.js";
|
|
9
|
+
import { withLf } from "./rename.js";
|
|
10
|
+
import { renderTemplate } from "./source.js";
|
|
11
|
+
const run = promisify(execFile);
|
|
12
|
+
const MODES = ["apply", "replace", "skip"];
|
|
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);
|
|
15
|
+
const requireCleanTree = async (cwd) => {
|
|
16
|
+
const { stdout } = await run("git", ["status", "--porcelain"], { cwd }).catch(() => {
|
|
17
|
+
throw new Error(`${cwd} is not a git repository; upgrade needs one so every change can be seen and undone`);
|
|
18
|
+
});
|
|
19
|
+
if (stdout.trim()) {
|
|
20
|
+
throw new Error("the git working tree has changes; commit or stash them first, so upgrade's changes stand alone");
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
const stateOf = (recorded, ours) => {
|
|
24
|
+
if (recorded === undefined)
|
|
25
|
+
return ours ? "new, a file exists here" : "new";
|
|
26
|
+
if (!ours)
|
|
27
|
+
return "deleted here";
|
|
28
|
+
return sha256(ours) === recorded ? "clean" : "edited";
|
|
29
|
+
};
|
|
30
|
+
export const mergeFile = async (ours, base, theirs) => {
|
|
31
|
+
const dir = await mkdtemp(join(tmpdir(), "cli-template-merge-"));
|
|
32
|
+
try {
|
|
33
|
+
const files = ["ours", "base", "theirs"].map((name) => join(dir, name));
|
|
34
|
+
await Promise.all([ours, base, theirs].map((content, index) => writeFile(files[index] ?? "", content)));
|
|
35
|
+
const args = ["merge-file", "-p", "-L", "this CLI", "-L", "template before", "-L", "template now", ...files];
|
|
36
|
+
return await run("git", args, { encoding: "buffer", maxBuffer: 64 * 1024 * 1024 }).then(({ stdout }) => ({ content: stdout, conflicts: 0 }), (error) => {
|
|
37
|
+
if (typeof error.code === "number" && error.code > 0 && error.code < 128 && error.stdout) {
|
|
38
|
+
return { content: error.stdout, conflicts: error.code };
|
|
39
|
+
}
|
|
40
|
+
throw new Error(`git merge-file failed: ${error.message}`);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
await rm(dir, { recursive: true, force: true });
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
const writeAs = async (path, content, mode) => {
|
|
48
|
+
await mkdir(dirname(path), { recursive: true });
|
|
49
|
+
await writeFile(path, content);
|
|
50
|
+
if (mode !== undefined)
|
|
51
|
+
await chmod(path, mode);
|
|
52
|
+
};
|
|
53
|
+
const diffText = async (before, after) => {
|
|
54
|
+
const dir = await mkdtemp(join(tmpdir(), "cli-template-diff-"));
|
|
55
|
+
try {
|
|
56
|
+
const [a, b] = [join(dir, "template-before"), join(dir, "template-now")];
|
|
57
|
+
await Promise.all([writeFile(a, before), writeFile(b, after)]);
|
|
58
|
+
return await run("git", ["diff", "--no-index", "--no-color", a, b]).then(({ stdout }) => stdout, (error) => error.stdout ?? "");
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
await rm(dir, { recursive: true, force: true });
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const hasMarkers = (content) => /^(<{7} |={7}$|>{7} )/m.test(content.toString("utf8"));
|
|
65
|
+
const writeManifest = (cwd, manifest) => writeFile(join(cwd, MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
66
|
+
// A conflict leaves the new version waiting in "pending"; it moves in once the markers are gone, so a
|
|
67
|
+
// resolved file is never merged a second time against the old base.
|
|
68
|
+
const finishPending = async (cwd, manifest) => {
|
|
69
|
+
const { pending, ...rest } = manifest;
|
|
70
|
+
if (!pending)
|
|
71
|
+
return manifest;
|
|
72
|
+
const left = [];
|
|
73
|
+
for (const path of pending.conflicts) {
|
|
74
|
+
const content = await readMaybe(join(cwd, path));
|
|
75
|
+
if (content && hasMarkers(content))
|
|
76
|
+
left.push(path);
|
|
77
|
+
}
|
|
78
|
+
if (left.length) {
|
|
79
|
+
throw new Error(`conflict markers are still in:\n${left.map((path) => ` ${path}`).join("\n")}\nResolve them, then run upgrade again.`);
|
|
80
|
+
}
|
|
81
|
+
const done = { ...rest, version: pending.version, files: pending.files };
|
|
82
|
+
await writeManifest(cwd, done);
|
|
83
|
+
return done;
|
|
84
|
+
};
|
|
85
|
+
export const runUpgrade = async (cwd, target, fetchVersion, options, io) => {
|
|
86
|
+
const recorded = await readCliManifest(cwd);
|
|
87
|
+
const manifest = options.dryRun ? recorded : await finishPending(cwd, recorded);
|
|
88
|
+
if (manifest !== recorded && manifest.version === target.version) {
|
|
89
|
+
return `Conflicts resolved; ${MANIFEST} is now on ${target.version}. Review with git diff, then commit.`;
|
|
90
|
+
}
|
|
91
|
+
if (manifest.version === target.version)
|
|
92
|
+
return `Already on ${target.name}@${target.version}.`;
|
|
93
|
+
if (!options.dryRun && manifest === recorded)
|
|
94
|
+
await requireCleanTree(cwd);
|
|
95
|
+
const seeds = [...new Set([...manifest.seeds, ...target.manifest.seeds])];
|
|
96
|
+
const renderedNow = await renderTemplate(target, manifest.names);
|
|
97
|
+
const owned = new Map([...renderedNow].filter(([path, file]) => file.kind === "file" && classify(path, [path], seeds) === "file"));
|
|
98
|
+
let before;
|
|
99
|
+
const baseOf = async (path) => {
|
|
100
|
+
before ??= fetchVersion(manifest.template, manifest.version).then(async ({ source, dispose }) => {
|
|
101
|
+
try {
|
|
102
|
+
return await renderTemplate(source, manifest.names);
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
await dispose();
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
return (await before).get(path)?.content ?? Buffer.alloc(0);
|
|
109
|
+
};
|
|
110
|
+
const changes = [];
|
|
111
|
+
for (const [path, theirs] of owned) {
|
|
112
|
+
const recorded = manifest.files[path];
|
|
113
|
+
if (recorded === sha256(theirs.content))
|
|
114
|
+
continue;
|
|
115
|
+
const ours = await readMaybe(join(cwd, path));
|
|
116
|
+
if (ours && sha256(ours) === sha256(theirs.content))
|
|
117
|
+
continue;
|
|
118
|
+
changes.push({ path, state: stateOf(recorded, ours), theirs, ours });
|
|
119
|
+
}
|
|
120
|
+
const dropped = Object.keys(manifest.files).filter((path) => !owned.has(path) && classify(path, [path], seeds) === "file");
|
|
121
|
+
let header = [`Upgrading from ${manifest.template}@${manifest.version} to ${target.version}.`];
|
|
122
|
+
if (dropped.length) {
|
|
123
|
+
header = header.concat("No longer part of the template, left in place for you to keep or delete:", dropped.map((path) => ` ${path}`));
|
|
124
|
+
}
|
|
125
|
+
const planned = async (change, mode) => {
|
|
126
|
+
if (mode === "skip")
|
|
127
|
+
return { outcome: "skipped" };
|
|
128
|
+
if (mode === "replace")
|
|
129
|
+
return { outcome: "replaced", content: change.theirs.content };
|
|
130
|
+
if (change.state === "deleted here")
|
|
131
|
+
return { outcome: "kept deleted" };
|
|
132
|
+
if (change.state === "clean" || change.state === "new" || !change.ours) {
|
|
133
|
+
return { outcome: "applied", content: change.theirs.content };
|
|
134
|
+
}
|
|
135
|
+
const base = change.state === "edited" ? await baseOf(change.path) : Buffer.alloc(0);
|
|
136
|
+
const merged = await mergeFile(change.ours, base, change.theirs.content);
|
|
137
|
+
return { outcome: merged.conflicts ? "conflict" : "applied", content: merged.content };
|
|
138
|
+
};
|
|
139
|
+
if (options.dryRun) {
|
|
140
|
+
const rows = await Promise.all(changes.map(async (change) => {
|
|
141
|
+
const what = options.mode
|
|
142
|
+
? (await planned(change, options.mode)).outcome
|
|
143
|
+
: "asked: apply, replace, skip or diff";
|
|
144
|
+
return ` ${change.path} (${change.state}): ${what}`;
|
|
145
|
+
}));
|
|
146
|
+
return [
|
|
147
|
+
...header,
|
|
148
|
+
changes.length ? "Would change:" : "No template-owned file changed.",
|
|
149
|
+
...rows,
|
|
150
|
+
"Dry run: nothing written.",
|
|
151
|
+
].join("\n");
|
|
152
|
+
}
|
|
153
|
+
if (changes.length && !options.mode && !io.interactive) {
|
|
154
|
+
throw new Error(`no terminal to ask in; pass --apply, --replace or --skip for every file, or --dry-run to list them:\n${changes
|
|
155
|
+
.map((change) => ` ${change.path} (${change.state})`)
|
|
156
|
+
.join("\n")}`);
|
|
157
|
+
}
|
|
158
|
+
const rl = changes.length && !options.mode
|
|
159
|
+
? createInterface({ input: io.input, output: io.output, terminal: false })
|
|
160
|
+
: undefined;
|
|
161
|
+
const choose = async (change) => {
|
|
162
|
+
if (options.mode || !rl)
|
|
163
|
+
return options.mode ?? "skip";
|
|
164
|
+
for (;;) {
|
|
165
|
+
const answer = (await rl.question(`${change.path} (${change.state}): [a]pply, [r]eplace, [s]kip, [d]iff? `)).trim();
|
|
166
|
+
const mode = MODES.find((candidate) => candidate === answer || candidate[0] === answer);
|
|
167
|
+
if (mode)
|
|
168
|
+
return mode;
|
|
169
|
+
if (answer === "d" || answer === "diff") {
|
|
170
|
+
const base = change.state === "new" || change.state === "new, a file exists here"
|
|
171
|
+
? Buffer.alloc(0)
|
|
172
|
+
: await baseOf(change.path);
|
|
173
|
+
io.output.write(await diffText(base, change.theirs.content));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
const results = [];
|
|
178
|
+
const conflicts = [];
|
|
179
|
+
const executable = [];
|
|
180
|
+
try {
|
|
181
|
+
for (const change of changes) {
|
|
182
|
+
const { outcome, content } = await planned(change, await choose(change));
|
|
183
|
+
if (content)
|
|
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);
|
|
187
|
+
if (outcome === "conflict")
|
|
188
|
+
conflicts.push(change.path);
|
|
189
|
+
results.push(` ${change.path} (${change.state}): ${outcome}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
rl?.close();
|
|
194
|
+
}
|
|
195
|
+
await markExecutable(cwd, executable);
|
|
196
|
+
const footer = ["Then re-run community's adopt-standards.py for config/wirecat/, which upgrade never touches."];
|
|
197
|
+
const files = Object.fromEntries([...owned].map(([path, file]) => [path, sha256(file.content)]));
|
|
198
|
+
if (conflicts.length) {
|
|
199
|
+
await writeManifest(cwd, { ...manifest, seeds, pending: { version: target.version, files, conflicts } });
|
|
200
|
+
return [
|
|
201
|
+
...header,
|
|
202
|
+
...results,
|
|
203
|
+
"",
|
|
204
|
+
`Conflicts left in ${conflicts.length} file(s); ${MANIFEST} stays on ${manifest.version} until they are resolved:`,
|
|
205
|
+
...conflicts.map((path) => ` ${path}`),
|
|
206
|
+
`Resolve the markers, then run upgrade again: it checks they are gone and moves ${MANIFEST} to ${target.version}.`,
|
|
207
|
+
...footer,
|
|
208
|
+
].join("\n");
|
|
209
|
+
}
|
|
210
|
+
const next = {
|
|
211
|
+
...manifest,
|
|
212
|
+
template: target.name,
|
|
213
|
+
version: target.version,
|
|
214
|
+
files,
|
|
215
|
+
seeds,
|
|
216
|
+
};
|
|
217
|
+
await writeManifest(cwd, next);
|
|
218
|
+
return [
|
|
219
|
+
...header,
|
|
220
|
+
...(results.length ? results : ["No template-owned file changed."]),
|
|
221
|
+
`${MANIFEST} is now on ${target.version}. Review with git diff, then commit.`,
|
|
222
|
+
...footer,
|
|
223
|
+
].join("\n");
|
|
224
|
+
};
|
|
225
|
+
export const parseMode = (flags) => {
|
|
226
|
+
const picked = MODES.filter((mode) => flags[mode]);
|
|
227
|
+
if (picked.length > 1)
|
|
228
|
+
throw new Error(`pick one of --apply, --replace or --skip, not ${picked.map((mode) => `--${mode}`).join(" and ")}`);
|
|
229
|
+
return picked[0];
|
|
230
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"lib": ["ES2023"],
|
|
5
|
+
"module": "NodeNext",
|
|
6
|
+
"moduleResolution": "NodeNext",
|
|
7
|
+
"types": ["node"],
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noUncheckedIndexedAccess": true,
|
|
10
|
+
"verbatimModuleSyntax": true,
|
|
11
|
+
"isolatedModules": true,
|
|
12
|
+
"skipLibCheck": true,
|
|
13
|
+
"rootDir": "src",
|
|
14
|
+
"outDir": "dist"
|
|
15
|
+
},
|
|
16
|
+
"include": ["src/**/*.ts"]
|
|
17
|
+
}
|
package/lefthook.yml
ADDED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[]
|