zinkee 0.1.10 → 0.1.12
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/.claude/settings.local.json +29 -1
- package/AGENTS.md +1 -0
- package/README.md +12 -0
- package/dist/index.js +748 -30
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cli-examples.ts +18 -36
- package/src/cli-runner.ts +28 -2
- package/src/completion/engine.test.ts +138 -0
- package/src/completion/engine.ts +168 -0
- package/src/completion/install.test.ts +179 -0
- package/src/completion/install.ts +260 -0
- package/src/completion/runtime.ts +150 -0
- package/src/completion/scripts.ts +91 -0
- package/src/index.test.ts +52 -0
- package/src/program.ts +3 -1
- package/src/utils/argv-rewrite.test.ts +149 -0
- package/src/utils/argv-rewrite.ts +269 -0
package/package.json
CHANGED
package/src/cli-examples.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { commandRegistry } from "./command-registry.js";
|
|
2
|
+
import {
|
|
3
|
+
collectOperandTokens,
|
|
4
|
+
createDefaultOptionMetadata,
|
|
5
|
+
findCommandPath,
|
|
6
|
+
type OptionMetadata,
|
|
7
|
+
} from "./utils/argv-rewrite.js";
|
|
2
8
|
import { CliError } from "./utils/errors.js";
|
|
3
9
|
import { getResolvedCommandExamples } from "./utils/examples.js";
|
|
4
10
|
import { buildJsonSuccess } from "./utils/output.js";
|
|
@@ -24,34 +30,6 @@ function getStdout(stdout?: CommandIo): CommandIo {
|
|
|
24
30
|
return stdout ?? process.stdout;
|
|
25
31
|
}
|
|
26
32
|
|
|
27
|
-
function resolveExampleCommand(tokens: readonly string[]): {
|
|
28
|
-
commandName: string;
|
|
29
|
-
remainingTokens: readonly string[];
|
|
30
|
-
} {
|
|
31
|
-
const commands = Object.keys(commandRegistry).sort(
|
|
32
|
-
(left, right) => right.split(" ").length - left.split(" ").length,
|
|
33
|
-
);
|
|
34
|
-
|
|
35
|
-
for (const commandName of commands) {
|
|
36
|
-
const commandTokens = commandName.split(" ");
|
|
37
|
-
const matches = commandTokens.every((token, index) => tokens[index] === token);
|
|
38
|
-
|
|
39
|
-
if (matches) {
|
|
40
|
-
return {
|
|
41
|
-
commandName,
|
|
42
|
-
remainingTokens: tokens.slice(commandTokens.length),
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const commandTokens = tokens.filter((token) => !token.startsWith("-"));
|
|
48
|
-
|
|
49
|
-
return {
|
|
50
|
-
commandName: commandTokens.join(" "),
|
|
51
|
-
remainingTokens: [],
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
|
|
55
33
|
function extractExampleFilters(tokens: readonly string[]): { type?: string } {
|
|
56
34
|
const filters: { type?: string } = {};
|
|
57
35
|
|
|
@@ -110,22 +88,26 @@ function extractExampleOverrides(tokens: readonly string[]): {
|
|
|
110
88
|
return overrides;
|
|
111
89
|
}
|
|
112
90
|
|
|
113
|
-
export function extractExampleRequest(
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
91
|
+
export function extractExampleRequest(
|
|
92
|
+
argv: readonly string[],
|
|
93
|
+
optionMeta: OptionMetadata = createDefaultOptionMetadata(),
|
|
94
|
+
): ExampleRequest | null {
|
|
95
|
+
if (!argv.includes("--example")) {
|
|
117
96
|
return null;
|
|
118
97
|
}
|
|
119
98
|
|
|
120
|
-
const
|
|
121
|
-
const
|
|
99
|
+
const userArgs = argv.slice(2);
|
|
100
|
+
const operands = collectOperandTokens(userArgs, optionMeta, { skipFlag: "--example" });
|
|
101
|
+
const cmdPath = findCommandPath(operands, commandRegistry);
|
|
122
102
|
|
|
123
|
-
if (
|
|
103
|
+
if (!cmdPath) {
|
|
124
104
|
throw new CliError("invalid_cli_usage", 'Provide a command after "--example".');
|
|
125
105
|
}
|
|
126
106
|
|
|
107
|
+
const remainingTokens = userArgs.slice(cmdPath.lastArgvIndex + 1);
|
|
108
|
+
|
|
127
109
|
return {
|
|
128
|
-
commandName,
|
|
110
|
+
commandName: cmdPath.commandName,
|
|
129
111
|
filters: extractExampleFilters(remainingTokens),
|
|
130
112
|
overrides: extractExampleOverrides(argv),
|
|
131
113
|
json: argv.includes("--json"),
|
package/src/cli-runner.ts
CHANGED
|
@@ -4,7 +4,18 @@ import { fileURLToPath } from "node:url";
|
|
|
4
4
|
import chalk from "chalk";
|
|
5
5
|
|
|
6
6
|
import { extractExampleRequest, showExamples } from "./cli-examples.js";
|
|
7
|
+
import { commandRegistry } from "./command-registry.js";
|
|
8
|
+
import {
|
|
9
|
+
extractCompletionFlag,
|
|
10
|
+
handleCompletionFlag,
|
|
11
|
+
isCompletionRequest,
|
|
12
|
+
runCompletionRequest,
|
|
13
|
+
} from "./completion/runtime.js";
|
|
7
14
|
import { buildProgram } from "./program.js";
|
|
15
|
+
import {
|
|
16
|
+
buildOptionMetadata,
|
|
17
|
+
hoistGlobalOptions,
|
|
18
|
+
} from "./utils/argv-rewrite.js";
|
|
8
19
|
import {
|
|
9
20
|
formatCliErrorJson,
|
|
10
21
|
formatCliErrorText,
|
|
@@ -37,7 +48,21 @@ export function isExecutedAsEntry(argvEntry: string | undefined, moduleUrl: stri
|
|
|
37
48
|
|
|
38
49
|
export async function runCli(argv: string[]): Promise<void> {
|
|
39
50
|
try {
|
|
40
|
-
|
|
51
|
+
if (isCompletionRequest()) {
|
|
52
|
+
runCompletionRequest();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const completionFlag = extractCompletionFlag(argv);
|
|
57
|
+
if (completionFlag) {
|
|
58
|
+
const exitCode = handleCompletionFlag(completionFlag);
|
|
59
|
+
process.exitCode = exitCode;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const program = buildProgram();
|
|
64
|
+
const optionMeta = buildOptionMetadata(program);
|
|
65
|
+
const exampleRequest = extractExampleRequest(argv, optionMeta);
|
|
41
66
|
|
|
42
67
|
if (exampleRequest) {
|
|
43
68
|
await showExamples(
|
|
@@ -53,7 +78,8 @@ export async function runCli(argv: string[]): Promise<void> {
|
|
|
53
78
|
throw new ExampleDisplayed();
|
|
54
79
|
}
|
|
55
80
|
|
|
56
|
-
|
|
81
|
+
const rewrittenArgv = hoistGlobalOptions(argv, optionMeta, commandRegistry);
|
|
82
|
+
await program.parseAsync(rewrittenArgv);
|
|
57
83
|
} catch (error) {
|
|
58
84
|
if (error instanceof ExampleDisplayed) {
|
|
59
85
|
process.exitCode = 0;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { buildProgram } from "../program.js";
|
|
4
|
+
import { getCompletions } from "./engine.js";
|
|
5
|
+
|
|
6
|
+
const NEVER_READ_PROFILES = (): readonly string[] => [];
|
|
7
|
+
|
|
8
|
+
describe("completion engine", () => {
|
|
9
|
+
it("suggests top-level subcommands and inherited globals", () => {
|
|
10
|
+
const program = buildProgram();
|
|
11
|
+
const result = getCompletions(
|
|
12
|
+
program,
|
|
13
|
+
{ words: ["zinkee", ""], cwordIndex: 1 },
|
|
14
|
+
{ readProfileNames: NEVER_READ_PROFILES },
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
expect(result).toContain("schemas");
|
|
18
|
+
expect(result).toContain("automations");
|
|
19
|
+
expect(result).toContain("--json");
|
|
20
|
+
expect(result).toContain("--profile");
|
|
21
|
+
expect(result).toContain("--help");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("filters subcommands by prefix", () => {
|
|
25
|
+
const program = buildProgram();
|
|
26
|
+
const result = getCompletions(
|
|
27
|
+
program,
|
|
28
|
+
{ words: ["zinkee", "sch"], cwordIndex: 1 },
|
|
29
|
+
{ readProfileNames: NEVER_READ_PROFILES },
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
expect(result).toEqual(["schemas"]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("descends into subcommands", () => {
|
|
36
|
+
const program = buildProgram();
|
|
37
|
+
const result = getCompletions(
|
|
38
|
+
program,
|
|
39
|
+
{ words: ["zinkee", "schemas", ""], cwordIndex: 2 },
|
|
40
|
+
{ readProfileNames: NEVER_READ_PROFILES },
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
expect(result).toContain("list");
|
|
44
|
+
expect(result).toContain("get");
|
|
45
|
+
expect(result).toContain("create");
|
|
46
|
+
expect(result).toContain("fields");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("descends into nested subcommands", () => {
|
|
50
|
+
const program = buildProgram();
|
|
51
|
+
const result = getCompletions(
|
|
52
|
+
program,
|
|
53
|
+
{ words: ["zinkee", "schemas", "fields", ""], cwordIndex: 3 },
|
|
54
|
+
{ readProfileNames: NEVER_READ_PROFILES },
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
expect(result).toContain("list");
|
|
58
|
+
expect(result).toContain("create");
|
|
59
|
+
expect(result).toContain("update");
|
|
60
|
+
expect(result).toContain("delete");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("only suggests long flags when the current token starts with --", () => {
|
|
64
|
+
const program = buildProgram();
|
|
65
|
+
const result = getCompletions(
|
|
66
|
+
program,
|
|
67
|
+
{ words: ["zinkee", "schemas", "list", "--"], cwordIndex: 3 },
|
|
68
|
+
{ readProfileNames: NEVER_READ_PROFILES },
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
expect(result.every((candidate) => candidate.startsWith("--"))).toBe(true);
|
|
72
|
+
expect(result).toContain("--json");
|
|
73
|
+
expect(result).toContain("--profile");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("skips flag tokens when descending into the command tree", () => {
|
|
77
|
+
const program = buildProgram();
|
|
78
|
+
const result = getCompletions(
|
|
79
|
+
program,
|
|
80
|
+
{
|
|
81
|
+
words: ["zinkee", "--profile", "prod", "schemas", ""],
|
|
82
|
+
cwordIndex: 4,
|
|
83
|
+
},
|
|
84
|
+
{ readProfileNames: NEVER_READ_PROFILES },
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
expect(result).toContain("list");
|
|
88
|
+
expect(result).toContain("fields");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("returns profile names when the previous token is --profile", () => {
|
|
92
|
+
const program = buildProgram();
|
|
93
|
+
const result = getCompletions(
|
|
94
|
+
program,
|
|
95
|
+
{ words: ["zinkee", "--profile", ""], cwordIndex: 2 },
|
|
96
|
+
{ readProfileNames: () => ["alpha", "beta"] },
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
expect(result).toEqual(["alpha", "beta"]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("filters profile names by prefix", () => {
|
|
103
|
+
const program = buildProgram();
|
|
104
|
+
const result = getCompletions(
|
|
105
|
+
program,
|
|
106
|
+
{ words: ["zinkee", "--profile", "be"], cwordIndex: 2 },
|
|
107
|
+
{ readProfileNames: () => ["alpha", "beta"] },
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
expect(result).toEqual(["beta"]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("does not crash when profile reader throws", () => {
|
|
114
|
+
const program = buildProgram();
|
|
115
|
+
const result = getCompletions(
|
|
116
|
+
program,
|
|
117
|
+
{ words: ["zinkee", "--profile", ""], cwordIndex: 2 },
|
|
118
|
+
{
|
|
119
|
+
readProfileNames: () => {
|
|
120
|
+
throw new Error("config unreadable");
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
expect(result).toEqual([]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("ignores hidden and unknown subcommands", () => {
|
|
129
|
+
const program = buildProgram();
|
|
130
|
+
const result = getCompletions(
|
|
131
|
+
program,
|
|
132
|
+
{ words: ["zinkee", "no-such-thing", ""], cwordIndex: 2 },
|
|
133
|
+
{ readProfileNames: NEVER_READ_PROFILES },
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
expect(result).toContain("schemas");
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
|
|
3
|
+
import { readConfigIfExists } from "../config.js";
|
|
4
|
+
|
|
5
|
+
export interface CompletionRequest {
|
|
6
|
+
words: readonly string[];
|
|
7
|
+
cwordIndex: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface CompletionDeps {
|
|
11
|
+
configPath?: string;
|
|
12
|
+
readProfileNames?: (configPath: string | undefined) => readonly string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function readProfileNamesFromConfig(configPath: string | undefined): readonly string[] {
|
|
16
|
+
try {
|
|
17
|
+
const config = readConfigIfExists(configPath);
|
|
18
|
+
if (!config) {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
return Object.keys(config.profiles).sort();
|
|
22
|
+
} catch {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function findSubcommand(command: Command, name: string): Command | undefined {
|
|
28
|
+
return command.commands.find((sub) => {
|
|
29
|
+
if (sub.name() === name) {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
const aliases = sub.aliases();
|
|
33
|
+
return aliases.includes(name);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function collectFlagsThatTakeValues(root: Command): Set<string> {
|
|
38
|
+
const flags = new Set<string>();
|
|
39
|
+
const walk = (command: Command): void => {
|
|
40
|
+
for (const option of command.options) {
|
|
41
|
+
if (!option.required && !option.optional) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (option.long) {
|
|
45
|
+
flags.add(option.long);
|
|
46
|
+
}
|
|
47
|
+
if (option.short) {
|
|
48
|
+
flags.add(option.short);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
for (const sub of command.commands) {
|
|
52
|
+
walk(sub);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
walk(root);
|
|
56
|
+
return flags;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function descend(root: Command, words: readonly string[]): Command {
|
|
60
|
+
const flagsWithValues = collectFlagsThatTakeValues(root);
|
|
61
|
+
let current = root;
|
|
62
|
+
let index = 0;
|
|
63
|
+
|
|
64
|
+
while (index < words.length) {
|
|
65
|
+
const word = words[index]!;
|
|
66
|
+
if (word.startsWith("-")) {
|
|
67
|
+
const [flagPart] = word.split("=", 1);
|
|
68
|
+
if (flagPart && flagsWithValues.has(flagPart) && !word.includes("=")) {
|
|
69
|
+
index += 2;
|
|
70
|
+
} else {
|
|
71
|
+
index += 1;
|
|
72
|
+
}
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const next = findSubcommand(current, word);
|
|
76
|
+
if (!next) {
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
current = next;
|
|
80
|
+
index += 1;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return current;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function collectVisibleSubcommands(command: Command): readonly string[] {
|
|
87
|
+
const names: string[] = [];
|
|
88
|
+
for (const sub of command.commands) {
|
|
89
|
+
if (sub.name().startsWith("__")) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
names.push(sub.name());
|
|
93
|
+
}
|
|
94
|
+
return names;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function collectVisibleLongFlags(command: Command): readonly string[] {
|
|
98
|
+
const flags: string[] = [];
|
|
99
|
+
let current: Command | null = command;
|
|
100
|
+
|
|
101
|
+
while (current) {
|
|
102
|
+
for (const option of current.options) {
|
|
103
|
+
if (option.hidden || !option.long) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (!flags.includes(option.long)) {
|
|
107
|
+
flags.push(option.long);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
current = current.parent;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!flags.includes("--help")) {
|
|
114
|
+
flags.push("--help");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return flags;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function getCompletions(
|
|
121
|
+
program: Command,
|
|
122
|
+
request: CompletionRequest,
|
|
123
|
+
deps: CompletionDeps = {},
|
|
124
|
+
): string[] {
|
|
125
|
+
const { words, cwordIndex } = request;
|
|
126
|
+
const safeIndex = Math.max(0, Math.min(cwordIndex, words.length));
|
|
127
|
+
const current = words[safeIndex] ?? "";
|
|
128
|
+
const prevToken = safeIndex > 0 ? words[safeIndex - 1] : undefined;
|
|
129
|
+
const readProfiles = deps.readProfileNames ?? readProfileNamesFromConfig;
|
|
130
|
+
|
|
131
|
+
if (prevToken === "--profile" || prevToken === "-p") {
|
|
132
|
+
try {
|
|
133
|
+
return readProfiles(deps.configPath)
|
|
134
|
+
.filter((name) => name.startsWith(current))
|
|
135
|
+
.slice()
|
|
136
|
+
.sort();
|
|
137
|
+
} catch {
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const tokensBeforeCurrent = words.slice(1, safeIndex);
|
|
143
|
+
const command = descend(program, tokensBeforeCurrent);
|
|
144
|
+
|
|
145
|
+
const candidates = new Set<string>();
|
|
146
|
+
|
|
147
|
+
if (current.startsWith("-")) {
|
|
148
|
+
for (const flag of collectVisibleLongFlags(command)) {
|
|
149
|
+
if (flag.startsWith(current)) {
|
|
150
|
+
candidates.add(flag);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return [...candidates].sort();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
for (const subcommand of collectVisibleSubcommands(command)) {
|
|
157
|
+
if (subcommand.startsWith(current)) {
|
|
158
|
+
candidates.add(subcommand);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
for (const flag of collectVisibleLongFlags(command)) {
|
|
162
|
+
if (flag.startsWith(current)) {
|
|
163
|
+
candidates.add(flag);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return [...candidates].sort();
|
|
168
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
CompletionShellError,
|
|
9
|
+
detectShell,
|
|
10
|
+
installCompletion,
|
|
11
|
+
parseShell,
|
|
12
|
+
resolveTarget,
|
|
13
|
+
type InstallEnvironment,
|
|
14
|
+
} from "./install.js";
|
|
15
|
+
|
|
16
|
+
const tempDirs: string[] = [];
|
|
17
|
+
|
|
18
|
+
function createTempHome(): string {
|
|
19
|
+
const dir = mkdtempSync(join(tmpdir(), "zinkee-install-"));
|
|
20
|
+
tempDirs.push(dir);
|
|
21
|
+
return dir;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function envFor(home: string, overrides: Partial<InstallEnvironment> = {}): InstallEnvironment {
|
|
25
|
+
return {
|
|
26
|
+
home,
|
|
27
|
+
platform: "linux",
|
|
28
|
+
shellEnv: "/bin/zsh",
|
|
29
|
+
...overrides,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
while (tempDirs.length > 0) {
|
|
35
|
+
rmSync(tempDirs.pop()!, { force: true, recursive: true });
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("detectShell", () => {
|
|
40
|
+
it("detects zsh from $SHELL", () => {
|
|
41
|
+
expect(detectShell({ home: "", platform: "linux", shellEnv: "/usr/bin/zsh" })).toBe("zsh");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("detects bash from $SHELL", () => {
|
|
45
|
+
expect(detectShell({ home: "", platform: "linux", shellEnv: "/bin/bash" })).toBe("bash");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("detects fish from $SHELL", () => {
|
|
49
|
+
expect(detectShell({ home: "", platform: "linux", shellEnv: "/usr/local/bin/fish" })).toBe(
|
|
50
|
+
"fish",
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("detects powershell from pwsh", () => {
|
|
55
|
+
expect(detectShell({ home: "", platform: "linux", shellEnv: "/usr/bin/pwsh" })).toBe(
|
|
56
|
+
"powershell",
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("falls back to powershell on win32 when $SHELL is unknown", () => {
|
|
61
|
+
expect(detectShell({ home: "", platform: "win32", shellEnv: undefined })).toBe("powershell");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("throws when shell cannot be inferred", () => {
|
|
65
|
+
expect(() =>
|
|
66
|
+
detectShell({ home: "", platform: "linux", shellEnv: "/bin/csh" }),
|
|
67
|
+
).toThrow(CompletionShellError);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("parseShell", () => {
|
|
72
|
+
it("normalizes pwsh to powershell", () => {
|
|
73
|
+
expect(parseShell("pwsh")).toBe("powershell");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("rejects unsupported names", () => {
|
|
77
|
+
expect(() => parseShell("nu")).toThrow(CompletionShellError);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("resolveTarget", () => {
|
|
82
|
+
it("uses $PROFILE resolver for powershell", () => {
|
|
83
|
+
const home = createTempHome();
|
|
84
|
+
const target = resolveTarget(
|
|
85
|
+
"powershell",
|
|
86
|
+
envFor(home, {
|
|
87
|
+
platform: "win32",
|
|
88
|
+
resolvePowerShellProfile: () => join(home, "Documents", "PowerShell", "profile.ps1"),
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
expect(target.scriptPath).toBe(join(home, "Documents", "PowerShell", "profile.ps1"));
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("installCompletion", () => {
|
|
96
|
+
it("installs zsh completion and updates rc with idempotent block", () => {
|
|
97
|
+
const home = createTempHome();
|
|
98
|
+
writeFileSync(join(home, ".zshrc"), "# user content\n", "utf8");
|
|
99
|
+
|
|
100
|
+
const first = installCompletion("zsh", envFor(home));
|
|
101
|
+
expect(first.rcUpdated).toBe(true);
|
|
102
|
+
const rcAfter = readFileSync(join(home, ".zshrc"), "utf8");
|
|
103
|
+
expect(rcAfter).toContain("# >>> zinkee completion >>>");
|
|
104
|
+
expect(rcAfter).toContain(`fpath=("${join(home, ".zfunc")}" $fpath)`);
|
|
105
|
+
expect(rcAfter).toContain("autoload -Uz compinit && compinit");
|
|
106
|
+
expect(rcAfter).toContain("# user content");
|
|
107
|
+
|
|
108
|
+
const second = installCompletion("zsh", envFor(home));
|
|
109
|
+
expect(second.rcUpdated).toBe(false);
|
|
110
|
+
const rcSecond = readFileSync(join(home, ".zshrc"), "utf8");
|
|
111
|
+
const beginCount = (rcSecond.match(/# >>> zinkee completion >>>/g) ?? []).length;
|
|
112
|
+
expect(beginCount).toBe(1);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("installs bash completion to ~/.bash_completions and sources it from .bashrc", () => {
|
|
116
|
+
const home = createTempHome();
|
|
117
|
+
const result = installCompletion("bash", envFor(home, { shellEnv: "/bin/bash" }));
|
|
118
|
+
expect(result.scriptPath).toBe(join(home, ".bash_completions", "zinkee.sh"));
|
|
119
|
+
expect(result.rcPath).toBe(join(home, ".bashrc"));
|
|
120
|
+
|
|
121
|
+
const rc = readFileSync(join(home, ".bashrc"), "utf8");
|
|
122
|
+
expect(rc).toContain(`source "${join(home, ".bash_completions", "zinkee.sh")}"`);
|
|
123
|
+
|
|
124
|
+
const script = readFileSync(result.scriptPath, "utf8");
|
|
125
|
+
expect(script).toContain("complete -o default -F _zinkee_completion zinkee");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("installs fish completion without touching any rc file", () => {
|
|
129
|
+
const home = createTempHome();
|
|
130
|
+
const result = installCompletion("fish", envFor(home, { shellEnv: "/usr/bin/fish" }));
|
|
131
|
+
expect(result.scriptPath).toBe(
|
|
132
|
+
join(home, ".config", "fish", "completions", "zinkee.fish"),
|
|
133
|
+
);
|
|
134
|
+
expect(result.rcUpdated).toBe(false);
|
|
135
|
+
|
|
136
|
+
const script = readFileSync(result.scriptPath, "utf8");
|
|
137
|
+
expect(script).toContain("complete --no-files --command zinkee");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("inlines powershell completion inside the resolved $PROFILE", () => {
|
|
141
|
+
const home = createTempHome();
|
|
142
|
+
const profilePath = join(home, "Documents", "PowerShell", "profile.ps1");
|
|
143
|
+
const result = installCompletion(
|
|
144
|
+
"powershell",
|
|
145
|
+
envFor(home, {
|
|
146
|
+
platform: "win32",
|
|
147
|
+
shellEnv: undefined,
|
|
148
|
+
resolvePowerShellProfile: () => profilePath,
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
expect(result.scriptPath).toBe(profilePath);
|
|
153
|
+
const content = readFileSync(profilePath, "utf8");
|
|
154
|
+
expect(content).toContain("# >>> zinkee completion >>>");
|
|
155
|
+
expect(content).toContain("Register-ArgumentCompleter -Native -CommandName zinkee");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("replaces an existing block instead of appending a new one", () => {
|
|
159
|
+
const home = createTempHome();
|
|
160
|
+
writeFileSync(
|
|
161
|
+
join(home, ".zshrc"),
|
|
162
|
+
[
|
|
163
|
+
"# top",
|
|
164
|
+
"# >>> zinkee completion >>>",
|
|
165
|
+
"stale-content",
|
|
166
|
+
"# <<< zinkee completion <<<",
|
|
167
|
+
"# bottom",
|
|
168
|
+
].join("\n") + "\n",
|
|
169
|
+
"utf8",
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
installCompletion("zsh", envFor(home));
|
|
173
|
+
const rc = readFileSync(join(home, ".zshrc"), "utf8");
|
|
174
|
+
expect(rc).not.toContain("stale-content");
|
|
175
|
+
expect(rc).toContain("# top");
|
|
176
|
+
expect(rc).toContain("# bottom");
|
|
177
|
+
expect((rc.match(/# >>> zinkee completion >>>/g) ?? []).length).toBe(1);
|
|
178
|
+
});
|
|
179
|
+
});
|