@starci/cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/README.md +8 -0
- package/bin/starci.mjs +18 -0
- package/completions/_starci +1171 -0
- package/completions/starci.bash +1145 -0
- package/completions/starci.fish +1343 -0
- package/completions/starci.ps1 +1151 -0
- package/package.json +33 -0
- package/src/catalog.generated.mjs +14680 -0
- package/src/completion.mjs +17 -0
- package/src/help.mjs +84 -0
- package/src/main.mjs +265 -0
- package/src/package-entry.spec.mjs +83 -0
- package/src/runtime-install.mjs +110 -0
- package/src/runtime-link.mjs +71 -0
- package/src/runtime-locate.mjs +54 -0
- package/src/shim.mjs +82 -0
- package/src/validate-args.mjs +170 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
|
|
4
|
+
const FILES = {
|
|
5
|
+
bash: 'starci.bash',
|
|
6
|
+
zsh: '_starci',
|
|
7
|
+
fish: 'starci.fish',
|
|
8
|
+
powershell: 'starci.ps1',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const completionShells = Object.freeze(Object.keys(FILES));
|
|
12
|
+
|
|
13
|
+
export function completionFor(shell, { read = readFileSync } = {}) {
|
|
14
|
+
const file = FILES[shell];
|
|
15
|
+
if (!file) return null;
|
|
16
|
+
return read(fileURLToPath(new URL(`../completions/${file}`, import.meta.url)), 'utf8');
|
|
17
|
+
}
|
package/src/help.mjs
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
const usageTail = (verb) => {
|
|
2
|
+
const positional = (verb.positional ?? []).map((entry) => {
|
|
3
|
+
const value = entry.enum?.length ? entry.enum.join('|') : entry.name;
|
|
4
|
+
const rendered = entry.variadic ? `${value}...` : value;
|
|
5
|
+
return entry.required ? `<${rendered}>` : `[${rendered}]`;
|
|
6
|
+
});
|
|
7
|
+
return positional.length ? ` ${positional.join(' ')}` : '';
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const rows = (entries) => {
|
|
11
|
+
const width = Math.max(0, ...entries.map(([name]) => name.length));
|
|
12
|
+
return entries.map(([name, summary]) => ` ${name.padEnd(width)} ${summary}`).join('\n');
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function topHelp(catalog, version = null) {
|
|
16
|
+
const groups = Object.entries(catalog.groups ?? {}).map(([name, group]) => [name, group.summary ?? '']);
|
|
17
|
+
const options = (catalog.global ?? []).map((flag) => [`--${flag.name}${flag.type === 'boolean' ? '' : ` <${flag.name}>`}`, flag.summary ?? '']);
|
|
18
|
+
const explainUsage = (catalog.commands ?? []).includes('explain') ? [' starci explain <group> <verb>'] : [];
|
|
19
|
+
return [
|
|
20
|
+
`starci${version ? ` ${version}` : ''} - one CLI for apps and the StarCi runtime`,
|
|
21
|
+
'',
|
|
22
|
+
'Usage: starci <group> <verb> [options]',
|
|
23
|
+
' starci help',
|
|
24
|
+
' starci completion <bash|zsh|fish|powershell>',
|
|
25
|
+
...explainUsage,
|
|
26
|
+
'',
|
|
27
|
+
'Groups:',
|
|
28
|
+
rows(groups),
|
|
29
|
+
'',
|
|
30
|
+
'Global options:',
|
|
31
|
+
rows(options),
|
|
32
|
+
'',
|
|
33
|
+
].join('\n');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function groupHelp(catalog, groupName) {
|
|
37
|
+
const group = catalog.groups?.[groupName];
|
|
38
|
+
if (!group) return null;
|
|
39
|
+
const verbs = Object.entries(group.verbs ?? {}).map(([name, verb]) => {
|
|
40
|
+
const effect = verb.effect && verb.effect !== 'read' ? `[${verb.effect}] ` : '';
|
|
41
|
+
return [name, `${effect}${verb.summary ?? ''}`];
|
|
42
|
+
});
|
|
43
|
+
return [
|
|
44
|
+
`starci ${groupName} - ${group.summary ?? ''}`,
|
|
45
|
+
'',
|
|
46
|
+
`Usage: starci ${groupName} <verb> [options]`,
|
|
47
|
+
'',
|
|
48
|
+
'Verbs:',
|
|
49
|
+
rows(verbs),
|
|
50
|
+
'',
|
|
51
|
+
].join('\n');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function verbHelp(catalog, groupName, verbName) {
|
|
55
|
+
const verb = catalog.groups?.[groupName]?.verbs?.[verbName];
|
|
56
|
+
if (!verb) return null;
|
|
57
|
+
const local = (verb.flags ?? []).map((flag) => {
|
|
58
|
+
const value = flag.type === 'boolean' ? '' : flag.type === 'enum' ? ` <${(flag.enum ?? []).join('|')}>` : ` <${flag.name}>`;
|
|
59
|
+
const required = flag.required ? ' (required)' : '';
|
|
60
|
+
return [`--${flag.name}${value}`, `${flag.summary ?? ''}${required}`];
|
|
61
|
+
});
|
|
62
|
+
const exits = Object.entries(verb.exit ?? {}).map(([code, summary]) => [code, summary]);
|
|
63
|
+
return [
|
|
64
|
+
`starci ${groupName} ${verbName} - ${verb.summary ?? ''}`,
|
|
65
|
+
'',
|
|
66
|
+
`Usage: starci ${groupName} ${verbName}${usageTail(verb)} [options]`,
|
|
67
|
+
...(local.length ? ['', 'Options:', rows(local)] : []),
|
|
68
|
+
...(verb.effect ? ['', `Effect: ${verb.effect}`] : []),
|
|
69
|
+
...(verb.roles?.length ? [`Roles: ${verb.roles.join(', ')}`] : []),
|
|
70
|
+
...(verb.conventions?.length ? ['', 'Conventions:', ...verb.conventions.map((line) => ` - ${line}`)] : []),
|
|
71
|
+
...(exits.length ? ['', 'Exit codes:', rows(exits)] : []),
|
|
72
|
+
...(verb.json ? ['', `JSON: ${verb.json}`] : []),
|
|
73
|
+
...((verb.examples ?? []).length ? ['', 'Examples:', ...(verb.examples ?? []).map((example) => ` ${example}`)] : []),
|
|
74
|
+
'',
|
|
75
|
+
].join('\n');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Full verb help plus the retired raw spellings the catalogued verb replaces. */
|
|
79
|
+
export function explainHelp(catalog, groupName, verbName) {
|
|
80
|
+
const verb = catalog.groups?.[groupName]?.verbs?.[verbName];
|
|
81
|
+
if (!verb) return null;
|
|
82
|
+
const help = verbHelp(catalog, groupName, verbName).trimEnd();
|
|
83
|
+
return `${help}${verb.removed?.length ? `\n\nReplaces: ${verb.removed.join(', ')}` : ''}\n`;
|
|
84
|
+
}
|
package/src/main.mjs
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { locateRuntime, runtimeEntryOf } from './runtime-locate.mjs';
|
|
7
|
+
import { runtimeEnv } from './shim.mjs';
|
|
8
|
+
|
|
9
|
+
const packageFile = fileURLToPath(new URL('../package.json', import.meta.url));
|
|
10
|
+
const cliVersion = () => JSON.parse(readFileSync(packageFile, 'utf8')).version;
|
|
11
|
+
const GUARD_FAST_PATH = Object.freeze({ command: 'command-guard.mjs', 'seat-tools': 'seat-tools.mjs' });
|
|
12
|
+
|
|
13
|
+
const importHfs = async () => {
|
|
14
|
+
try {
|
|
15
|
+
return await import('@starci/hfs');
|
|
16
|
+
} catch (error) {
|
|
17
|
+
if (error?.code !== 'ERR_MODULE_NOT_FOUND' || !String(error?.message ?? '').includes('@starci/hfs')) throw error;
|
|
18
|
+
// The StarCi runtime ships packages/cli and packages/hfs side by side without
|
|
19
|
+
// making the repository an npm workspace. Published @starci/cli resolves its
|
|
20
|
+
// normal exact dependency above; the embedded runtime uses this payload path.
|
|
21
|
+
return import(new URL('../../hfs/src/main.mjs', import.meta.url));
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const writeTo = (target, text) => {
|
|
26
|
+
if (typeof target === 'function') target(text);
|
|
27
|
+
else target.write(text);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const noRuntime = (stderr, group, skipped) => {
|
|
31
|
+
for (const note of skipped) writeTo(stderr, `starci: ignored: ${note}\n`);
|
|
32
|
+
return fail(stderr, `the runtime group "${group}" needs the StarCi runtime, which is not installed (run: starci runtime install)`, 3);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const fail = (stderr, message, code = 2) => {
|
|
36
|
+
writeTo(stderr, `starci: ${message}\n`);
|
|
37
|
+
return code;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The hooks run on every tool call. Exact hook invocations bypass the generated catalog, help modules and a second
|
|
42
|
+
* Node process; help and malformed invocations fall through to the catalog-driven dispatcher below.
|
|
43
|
+
*/
|
|
44
|
+
const guardFastPath = async (argv, io) => {
|
|
45
|
+
if (argv.length !== 2 || argv[0] !== 'guard' || !Object.hasOwn(GUARD_FAST_PATH, argv[1])) return null;
|
|
46
|
+
const stderr = io.stderr ?? process.stderr;
|
|
47
|
+
const cwd = io.cwd ?? process.cwd();
|
|
48
|
+
const skipped = [];
|
|
49
|
+
const located = (io.locateRuntime ?? locateRuntime)({ cwd, env: io.env ?? process.env, skipped, ...(io.home ? { home: io.home } : {}) });
|
|
50
|
+
if (!located) return noRuntime(stderr, 'guard', skipped);
|
|
51
|
+
try {
|
|
52
|
+
const file = path.join(located.root, 'scripts', 'guards', GUARD_FAST_PATH[argv[1]]);
|
|
53
|
+
const guard = await (io.importGuard ?? ((target) => import(pathToFileURL(target).href)))(file);
|
|
54
|
+
if (typeof guard.main !== 'function') return fail(stderr, `guard ${argv[1]} has no in-process entry`, 1);
|
|
55
|
+
return Number(await guard.main({
|
|
56
|
+
stdin: io.stdin ?? process.stdin,
|
|
57
|
+
stdout: io.stdout ?? process.stdout,
|
|
58
|
+
stderr,
|
|
59
|
+
env: io.env ?? process.env,
|
|
60
|
+
})) || 0;
|
|
61
|
+
} catch (error) {
|
|
62
|
+
return fail(stderr, `cannot load guard ${argv[1]}: ${error?.message ?? error}`, 1);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// `starci <old>` spellings match from the program name; the retired `hfs` and `starci-test-stack` bins also match behind it.
|
|
67
|
+
const RETIRED_BINS = new Set(['hfs', 'starci-test-stack']);
|
|
68
|
+
const retiredMatch = (words, retired) => {
|
|
69
|
+
const input = ['starci', ...words];
|
|
70
|
+
const candidates = retired
|
|
71
|
+
.map((entry) => {
|
|
72
|
+
const tokens = entry.spelling.split(/\s+/);
|
|
73
|
+
if (tokens[0] === 'starci') return { ...entry, tokens, offset: 0 };
|
|
74
|
+
return RETIRED_BINS.has(tokens[0]) ? { ...entry, tokens, offset: 1 } : null;
|
|
75
|
+
})
|
|
76
|
+
.filter((entry) => entry && entry.tokens.every((token, index) => input[index + entry.offset] === token))
|
|
77
|
+
.sort((a, b) => b.tokens.length - a.tokens.length);
|
|
78
|
+
const match = candidates[0];
|
|
79
|
+
if (!match) return null;
|
|
80
|
+
const suffix = input.slice(match.offset + match.tokens.length);
|
|
81
|
+
return {
|
|
82
|
+
spelling: [...input.slice(0, match.offset), ...match.tokens, ...suffix].join(' '),
|
|
83
|
+
use: [...match.use.split(/\s+/), ...suffix].join(' '),
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const handlerArgs = (validated, command, withFlagsBeforeDashes, { includeQuiet = true } = {}) => {
|
|
88
|
+
const extra = [];
|
|
89
|
+
if (validated.global.json === true && command.json !== 'always') extra.push('--json');
|
|
90
|
+
if (validated.global.edition !== undefined) extra.push('--edition', validated.global.edition);
|
|
91
|
+
if (includeQuiet && validated.global.quiet === true) extra.push('--quiet');
|
|
92
|
+
return withFlagsBeforeDashes(validated.localArgs, extra);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** The published CLI entry, with I/O and process seams for deterministic specs. */
|
|
96
|
+
export async function main(argv = process.argv.slice(2), io = {}) {
|
|
97
|
+
const guarded = await guardFastPath(argv, io);
|
|
98
|
+
if (guarded !== null) return guarded;
|
|
99
|
+
const [catalogModule, completionModule, helpModule, installModule, linkModule, argsModule] = await Promise.all([
|
|
100
|
+
import('./catalog.generated.mjs'),
|
|
101
|
+
import('./completion.mjs'),
|
|
102
|
+
import('./help.mjs'),
|
|
103
|
+
import('./runtime-install.mjs'),
|
|
104
|
+
import('./runtime-link.mjs'),
|
|
105
|
+
import('./validate-args.mjs'),
|
|
106
|
+
]);
|
|
107
|
+
const { CATALOG, RETIRED } = catalogModule;
|
|
108
|
+
const { completionFor, completionShells } = completionModule;
|
|
109
|
+
const { explainHelp, groupHelp, topHelp, verbHelp } = helpModule;
|
|
110
|
+
const { installRuntime } = installModule;
|
|
111
|
+
const { linkRuntime } = linkModule;
|
|
112
|
+
const { splitCommand, validateArgs, withFlagsBeforeDashes } = argsModule;
|
|
113
|
+
const stdout = io.stdout ?? process.stdout;
|
|
114
|
+
const stderr = io.stderr ?? process.stderr;
|
|
115
|
+
const catalog = io.catalog ?? CATALOG;
|
|
116
|
+
const retired = io.retired ?? RETIRED;
|
|
117
|
+
const version = io.version ?? cliVersion();
|
|
118
|
+
const home = io.home;
|
|
119
|
+
const env = io.env ?? process.env;
|
|
120
|
+
const initialCwd = io.cwd ?? process.cwd();
|
|
121
|
+
|
|
122
|
+
if (argv.length === 0 || (argv.length === 1 && ['help', '--help', '-h'].includes(argv[0]))) {
|
|
123
|
+
writeTo(stdout, topHelp(catalog, version));
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
if (argv.length === 1 && argv[0] === '--version') {
|
|
127
|
+
writeTo(stdout, `${version}\n`);
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const split = splitCommand(argv, catalog.global ?? []);
|
|
132
|
+
const removed = split.group ? retiredMatch([split.group, ...(split.verb ? [split.verb] : []), ...split.args], retired) : null;
|
|
133
|
+
if (removed) return fail(stderr, `"${removed.spelling}" was removed; use "${removed.use}"`);
|
|
134
|
+
|
|
135
|
+
if (split.group === 'help') {
|
|
136
|
+
const [verbName] = split.args.filter((token) => !token.startsWith('-'));
|
|
137
|
+
const target = split.verb ? catalog.groups?.[split.verb] : null;
|
|
138
|
+
if (split.verb && !target) return fail(stderr, `unknown group "${split.verb}"`);
|
|
139
|
+
if (verbName && !target.verbs?.[verbName]) return fail(stderr, `unknown verb "${verbName}" for group "${split.verb}"`);
|
|
140
|
+
writeTo(stdout, !split.verb ? topHelp(catalog, version) : verbName ? verbHelp(catalog, split.verb, verbName) : groupHelp(catalog, split.verb));
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
143
|
+
if (split.group === 'completion') {
|
|
144
|
+
const shell = split.verb;
|
|
145
|
+
if (split.help && !split.args.length) {
|
|
146
|
+
writeTo(stdout, `Usage: starci completion <${completionShells.join('|')}>\n`);
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
if (!completionShells.includes(shell) || split.args.length) {
|
|
150
|
+
return fail(stderr, `completion expects one of: ${completionShells.join(', ')}`);
|
|
151
|
+
}
|
|
152
|
+
const text = (io.completionFor ?? completionFor)(shell);
|
|
153
|
+
if (text == null) return fail(stderr, `completion expects one of: ${completionShells.join(', ')}`);
|
|
154
|
+
writeTo(stdout, text);
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
if (split.group === 'explain' && (catalog.commands ?? []).includes('explain')) {
|
|
158
|
+
if (split.help && !split.verb && !split.args.length) {
|
|
159
|
+
writeTo(stdout, 'Usage: starci explain <group> <verb>\n');
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
const groupName = split.verb;
|
|
163
|
+
const verbNames = split.args.filter((token) => !token.startsWith('-'));
|
|
164
|
+
if (!groupName || verbNames.length !== 1) return fail(stderr, 'explain expects <group> <verb>');
|
|
165
|
+
const target = catalog.groups?.[groupName];
|
|
166
|
+
if (!target) return fail(stderr, `unknown group "${groupName}"`);
|
|
167
|
+
const verbName = verbNames[0];
|
|
168
|
+
if (!target.verbs?.[verbName]) {
|
|
169
|
+
return fail(stderr, `unknown verb "${verbName}" for group "${groupName}" (available: ${Object.keys(target.verbs ?? {}).join(', ')})`);
|
|
170
|
+
}
|
|
171
|
+
writeTo(stdout, explainHelp(catalog, groupName, verbName));
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const wantsHelp = split.help;
|
|
176
|
+
if (!split.group) {
|
|
177
|
+
if (wantsHelp) {
|
|
178
|
+
writeTo(stdout, topHelp(catalog, version));
|
|
179
|
+
return 0;
|
|
180
|
+
}
|
|
181
|
+
return fail(stderr, 'missing command group');
|
|
182
|
+
}
|
|
183
|
+
const group = catalog.groups?.[split.group];
|
|
184
|
+
if (!group) return fail(stderr, `unknown group "${split.group}"`);
|
|
185
|
+
if (!split.verb) {
|
|
186
|
+
if (wantsHelp) {
|
|
187
|
+
writeTo(stdout, groupHelp(catalog, split.group));
|
|
188
|
+
return 0;
|
|
189
|
+
}
|
|
190
|
+
return fail(stderr, `missing verb for group "${split.group}"`);
|
|
191
|
+
}
|
|
192
|
+
const command = group.verbs?.[split.verb];
|
|
193
|
+
if (!command) return fail(stderr, `unknown verb "${split.verb}" for group "${split.group}"`);
|
|
194
|
+
if (wantsHelp) {
|
|
195
|
+
writeTo(stdout, verbHelp(catalog, split.group, split.verb));
|
|
196
|
+
return 0;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const validated = validateArgs(split.args, { ...command, group: split.group, verb: split.verb }, catalog.global ?? []);
|
|
200
|
+
if (!validated.ok) return fail(stderr, validated.error, validated.code);
|
|
201
|
+
const cwd = path.resolve(initialCwd, validated.global.cwd ?? '.');
|
|
202
|
+
const args = handlerArgs(validated, command, withFlagsBeforeDashes);
|
|
203
|
+
|
|
204
|
+
if (split.group === 'runtime' && split.verb === 'install') {
|
|
205
|
+
const install = io.installRuntime ?? installRuntime;
|
|
206
|
+
return await install({
|
|
207
|
+
cwd,
|
|
208
|
+
...(home ? { home } : {}),
|
|
209
|
+
force: validated.values.force === true,
|
|
210
|
+
hosts: validated.values.hosts ?? null,
|
|
211
|
+
noBootstrap: validated.values['no-bootstrap'] === true,
|
|
212
|
+
}, io.runtimeInstallDeps ?? {});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (split.group === 'runtime' && split.verb === 'link') {
|
|
216
|
+
const link = io.linkRuntime ?? linkRuntime;
|
|
217
|
+
return await link({
|
|
218
|
+
cwd,
|
|
219
|
+
...(home ? { home } : {}),
|
|
220
|
+
root: validated.values.root ?? null,
|
|
221
|
+
json: validated.global.json === true,
|
|
222
|
+
quiet: validated.global.quiet === true,
|
|
223
|
+
stdout,
|
|
224
|
+
stderr,
|
|
225
|
+
}, io.runtimeLinkDeps ?? {});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (group.owner === '@starci/hfs') {
|
|
229
|
+
try {
|
|
230
|
+
const hfs = await (io.importHfs ?? importHfs)();
|
|
231
|
+
const entry = hfs.main ?? hfs.default?.main;
|
|
232
|
+
if (typeof entry !== 'function') return fail(stderr, '@starci/hfs does not export main(argv, io)', 1);
|
|
233
|
+
return Number(await entry([split.verb, ...args], {
|
|
234
|
+
...io,
|
|
235
|
+
cwd,
|
|
236
|
+
stdout: (text) => writeTo(stdout, text),
|
|
237
|
+
stderr: (text) => writeTo(stderr, text),
|
|
238
|
+
})) || 0;
|
|
239
|
+
} catch (error) {
|
|
240
|
+
return fail(stderr, `cannot load @starci/hfs: ${error?.message ?? error}`, 1);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const skipped = [];
|
|
245
|
+
const located = (io.locateRuntime ?? locateRuntime)({ cwd, env, skipped, ...(home ? { home } : {}) });
|
|
246
|
+
if (!located) return noRuntime(stderr, split.group, skipped);
|
|
247
|
+
const spawn = io.spawn ?? spawnSync;
|
|
248
|
+
let result;
|
|
249
|
+
try {
|
|
250
|
+
result = spawn(process.execPath, [runtimeEntryOf(located.root), split.group, split.verb, ...args], {
|
|
251
|
+
cwd,
|
|
252
|
+
env: runtimeEnv(env, home ? { home } : {}),
|
|
253
|
+
stdio: 'inherit',
|
|
254
|
+
windowsHide: true,
|
|
255
|
+
});
|
|
256
|
+
} catch (error) {
|
|
257
|
+
return fail(stderr, `cannot run the runtime: ${error?.message ?? error}`, 1);
|
|
258
|
+
}
|
|
259
|
+
if (result?.error) {
|
|
260
|
+
if (result.error.code === 'ENOENT' && !existsSync(cwd)) return fail(stderr, `--cwd "${cwd}" is not a directory`);
|
|
261
|
+
return fail(stderr, `cannot run the runtime: ${result.error.message ?? result.error}`, 1);
|
|
262
|
+
}
|
|
263
|
+
if (result?.status == null) return fail(stderr, `the runtime stopped on signal ${result?.signal ?? 'unknown'}`, 1);
|
|
264
|
+
return result.status;
|
|
265
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { lstatSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import test from 'node:test';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { main } from './main.mjs';
|
|
9
|
+
|
|
10
|
+
const bin = fileURLToPath(new URL('../bin/starci.mjs', import.meta.url));
|
|
11
|
+
const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
12
|
+
const run = (...args) => spawnSync(process.execPath, [bin, ...args], { encoding: 'utf8', windowsHide: true, timeout: 30_000 });
|
|
13
|
+
|
|
14
|
+
test('the exact installed HFS dependency loads its real published module graph', async () => {
|
|
15
|
+
const file = fileURLToPath(import.meta.resolve('@starci/hfs/package.json'));
|
|
16
|
+
const installed = JSON.parse(readFileSync(file, 'utf8'));
|
|
17
|
+
assert.equal(installed.name, '@starci/hfs');
|
|
18
|
+
const declared = manifest.dependencies[installed.name];
|
|
19
|
+
if (declared.startsWith('file:')) {
|
|
20
|
+
const archive = path.resolve(fileURLToPath(new URL('../', import.meta.url)), declared.slice('file:'.length));
|
|
21
|
+
assert.equal(path.extname(archive), '.tgz');
|
|
22
|
+
assert.equal(lstatSync(archive).isFile(), true);
|
|
23
|
+
// The clean-package owner verifies the original exact version and archive identity in preparePackedDependencies,
|
|
24
|
+
// then every installed archive byte in verifyPackedDependencies, before running this spec with its scratch alias.
|
|
25
|
+
} else assert.equal(installed.version, declared);
|
|
26
|
+
const root = path.dirname(file);
|
|
27
|
+
assert.equal(lstatSync(root).isDirectory(), true);
|
|
28
|
+
assert.equal(realpathSync.native(root), path.join(realpathSync.native(fileURLToPath(new URL('../', import.meta.url))), 'node_modules', '@starci', 'hfs'));
|
|
29
|
+
const hfs = await import('@starci/hfs');
|
|
30
|
+
assert.equal(typeof hfs.main, 'function');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('the real binary dispatches finite app explanations through installed HFS', (t) => {
|
|
34
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'starci-app-main-'));
|
|
35
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
36
|
+
const declaration = JSON.stringify({ hfs: 2, kind: 'app', project: 'package-entry', sides: {
|
|
37
|
+
be: { apps: [{ name: 'api', kind: 'api' }] },
|
|
38
|
+
fe: { apps: [{ name: 'web', kind: 'next' }], reads: ['be/contracts/'] },
|
|
39
|
+
} });
|
|
40
|
+
writeFileSync(path.join(cwd, 'hfs.json'), declaration);
|
|
41
|
+
const owned = run('app', 'explain', 'hfs.json', '--cwd', cwd, '--json');
|
|
42
|
+
assert.equal(owned.status, 0, owned.stderr || String(owned.error ?? ''));
|
|
43
|
+
const explained = JSON.parse(owned.stdout);
|
|
44
|
+
assert.deepEqual([explained.path, explained.status, explained.slot], ['hfs.json', 'owned', 'app.declaration']);
|
|
45
|
+
const unowned = run('app', 'explain', 'unowned.fixture', '--cwd', cwd, '--json');
|
|
46
|
+
assert.equal(unowned.status, 1, unowned.stderr || String(unowned.error ?? ''));
|
|
47
|
+
const refused = JSON.parse(unowned.stdout);
|
|
48
|
+
assert.deepEqual([refused.path, refused.status, refused.code], ['unowned.fixture', 'no-slot', 'HFS_SLOT_UNDECLARED']);
|
|
49
|
+
assert.equal(readFileSync(path.join(cwd, 'hfs.json'), 'utf8'), declaration);
|
|
50
|
+
assert.deepEqual(readdirSync(cwd), ['hfs.json']);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('the published binary serves its own version and catalog help', () => {
|
|
54
|
+
const version = run('--version');
|
|
55
|
+
assert.equal(version.status, 0, version.stderr);
|
|
56
|
+
assert.equal(version.stdout.trim(), manifest.version);
|
|
57
|
+
const help = run('--help');
|
|
58
|
+
assert.equal(help.status, 0, help.stderr);
|
|
59
|
+
assert.match(help.stdout, /Usage: starci <group> <verb>/);
|
|
60
|
+
assert.match(help.stdout, /Groups:/);
|
|
61
|
+
const appHelp = run('app', 'check', '--help');
|
|
62
|
+
assert.equal(appHelp.status, 0, appHelp.stderr);
|
|
63
|
+
assert.match(appHelp.stdout, /starci app check/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('the published binary refuses unknown commands and invalid flags', () => {
|
|
67
|
+
const unknown = run('package-entry-unknown');
|
|
68
|
+
assert.equal(unknown.status, 2);
|
|
69
|
+
assert.match(unknown.stderr, /unknown group/);
|
|
70
|
+
const invalid = run('app', 'check', '--package-entry-unknown');
|
|
71
|
+
assert.equal(invalid.status, 2);
|
|
72
|
+
assert.match(invalid.stderr, /unknown (?:flag|option)/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('the package dispatcher preserves app arguments, cwd and owner exit status', async () => {
|
|
76
|
+
let called;
|
|
77
|
+
const code = await main(['--cwd', 'selected-app', 'app', 'check', '--json'], {
|
|
78
|
+
cwd: path.resolve('package-entry-base'), stdout: () => {}, stderr: () => {},
|
|
79
|
+
importHfs: async () => ({ main: async (argv, io) => { called = { argv, cwd: io.cwd }; return 1; } }),
|
|
80
|
+
});
|
|
81
|
+
assert.equal(code, 1);
|
|
82
|
+
assert.deepEqual(called, { argv: ['check', '--json'], cwd: path.resolve('package-entry-base', 'selected-app') });
|
|
83
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { writeRuntimeShim } from './shim.mjs';
|
|
8
|
+
|
|
9
|
+
export const RUNTIME_VERSION = '1.0.0-alpha.4';
|
|
10
|
+
|
|
11
|
+
const writeTo = (target, text) => {
|
|
12
|
+
if (typeof target === 'function') target(text);
|
|
13
|
+
else target.write(text);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const npmEntryCandidates = ({ execPath = process.execPath, env = process.env } = {}) => {
|
|
17
|
+
const nodeDirectory = path.dirname(path.resolve(execPath));
|
|
18
|
+
const candidates = [];
|
|
19
|
+
if (typeof env?.npm_execpath === 'string' && path.basename(env.npm_execpath).toLowerCase() === 'npm-cli.js') {
|
|
20
|
+
candidates.push(path.resolve(env.npm_execpath));
|
|
21
|
+
}
|
|
22
|
+
candidates.push(path.join(nodeDirectory, 'node_modules', 'npm', 'bin', 'npm-cli.js'));
|
|
23
|
+
candidates.push(path.join(path.dirname(nodeDirectory), 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'));
|
|
24
|
+
return [...new Set(candidates)];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Find npm's JavaScript entry so Node can execute it without a command shell. */
|
|
28
|
+
export function resolveNpmEntry({ execPath = process.execPath, env = process.env, exists = existsSync } = {}) {
|
|
29
|
+
return npmEntryCandidates({ execPath, env }).find((candidate) => exists(candidate)) ?? null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const statusOf = (result, label, report) => {
|
|
33
|
+
if (typeof result === 'number') return result;
|
|
34
|
+
if (result?.error) {
|
|
35
|
+
report(`${label} failed to start: ${result.error.message ?? result.error}`);
|
|
36
|
+
return 1;
|
|
37
|
+
}
|
|
38
|
+
if (result?.status == null) {
|
|
39
|
+
report(`${label} stopped on signal ${result?.signal ?? 'unknown'}`);
|
|
40
|
+
return 1;
|
|
41
|
+
}
|
|
42
|
+
return result.status;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Install the pinned runtime package and run its own installer.
|
|
47
|
+
* All network/process work is injectable; specs never need npm or the network.
|
|
48
|
+
*/
|
|
49
|
+
export function installRuntime({ cwd = process.cwd(), home = os.homedir(), force = false, hosts = null, noBootstrap = false, stderr = process.stderr } = {}, deps = {}) {
|
|
50
|
+
const exists = deps.exists ?? existsSync;
|
|
51
|
+
const mkdir = deps.mkdir ?? ((directory) => mkdirSync(directory, { recursive: true }));
|
|
52
|
+
const node = process.execPath;
|
|
53
|
+
const env = deps.env ?? process.env;
|
|
54
|
+
const spawn = deps.spawn ?? spawnSync;
|
|
55
|
+
const report = (message) => writeTo(deps.stderr ?? stderr, `starci: ${message}\n`);
|
|
56
|
+
const npmEntryResolver = deps.resolveNpmEntry ?? resolveNpmEntry;
|
|
57
|
+
const runNpm = deps.runNpm ?? ((args, options) => {
|
|
58
|
+
const npmEntry = npmEntryResolver({ execPath: node, env, exists });
|
|
59
|
+
if (!npmEntry) {
|
|
60
|
+
const probed = npmEntryCandidates({ execPath: node, env }).map((candidate) => `"${candidate}"`).join(', ');
|
|
61
|
+
report(`cannot find npm-cli.js; probed: ${probed}. Install npm alongside Node.js or set npm_execpath to an existing npm-cli.js.`);
|
|
62
|
+
return 1;
|
|
63
|
+
}
|
|
64
|
+
return spawn(node, [npmEntry, ...args], options);
|
|
65
|
+
});
|
|
66
|
+
const runNode = deps.runNode ?? ((args, options) => spawn(node, args, options));
|
|
67
|
+
const installRoot = path.join(home, '.starci', 'runtime');
|
|
68
|
+
const packageSpec = `starci@${deps.runtimeVersion ?? RUNTIME_VERSION}`;
|
|
69
|
+
const fetchRuntime = deps.fetchRuntime ?? (() => runNpm(['install', '--prefix', installRoot, packageSpec], {
|
|
70
|
+
cwd,
|
|
71
|
+
stdio: 'inherit',
|
|
72
|
+
windowsHide: true,
|
|
73
|
+
shell: false,
|
|
74
|
+
}));
|
|
75
|
+
|
|
76
|
+
mkdir(installRoot);
|
|
77
|
+
let fetched;
|
|
78
|
+
try {
|
|
79
|
+
fetched = fetchRuntime({ installRoot, packageSpec, cwd });
|
|
80
|
+
} catch (error) {
|
|
81
|
+
report(`npm install failed to start: ${error?.message ?? error}`);
|
|
82
|
+
return 1;
|
|
83
|
+
}
|
|
84
|
+
const fetchStatus = statusOf(fetched, 'npm install', report);
|
|
85
|
+
if (fetchStatus !== 0) return fetchStatus;
|
|
86
|
+
|
|
87
|
+
const runtimeRoot = path.join(installRoot, 'node_modules', 'starci');
|
|
88
|
+
const installer = path.join(runtimeRoot, 'scripts', 'install', 'install.mjs');
|
|
89
|
+
if (!exists(installer)) {
|
|
90
|
+
report(`installed runtime is missing its installer: ${installer}`);
|
|
91
|
+
return 1;
|
|
92
|
+
}
|
|
93
|
+
const verb = exists(path.join(cwd, '.claude')) ? 'update' : 'init';
|
|
94
|
+
const installerArgs = [installer, verb, '--dir', path.resolve(cwd)];
|
|
95
|
+
if (force) installerArgs.push('--force');
|
|
96
|
+
if (hosts) installerArgs.push('--hosts', hosts);
|
|
97
|
+
if (noBootstrap) installerArgs.push('--no-bootstrap');
|
|
98
|
+
let installed;
|
|
99
|
+
try {
|
|
100
|
+
installed = runNode(installerArgs, { cwd, stdio: 'inherit', windowsHide: true, shell: false });
|
|
101
|
+
} catch (error) {
|
|
102
|
+
report(`runtime installer failed to start: ${error?.message ?? error}`);
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
const installStatus = statusOf(installed, 'runtime installer', report);
|
|
106
|
+
if (installStatus !== 0) return installStatus;
|
|
107
|
+
|
|
108
|
+
writeRuntimeShim({ root: runtimeRoot, home, cli: fileURLToPath(new URL('../bin/starci.mjs', import.meta.url)) }, deps);
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
import { writeRuntimeShim } from './shim.mjs';
|
|
7
|
+
|
|
8
|
+
export const RUNTIME_ROOT_FILES = Object.freeze([
|
|
9
|
+
path.join('scripts', 'cli', 'main.mjs'),
|
|
10
|
+
path.join('packages', 'cli', 'bin', 'starci.mjs'),
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
const writeTo = (target, text) => {
|
|
14
|
+
if (typeof target === 'function') target(text);
|
|
15
|
+
else target.write(text);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const missingAt = (root, exists) => RUNTIME_ROOT_FILES.filter((relative) => !exists(path.join(root, relative)));
|
|
19
|
+
|
|
20
|
+
const hookLines = (result) => result?.ok
|
|
21
|
+
? result.hooks.map((hook) => `git hook ${hook.name}: ${hook.state} ${hook.path}`)
|
|
22
|
+
: [`git hooks: ${result?.reason ?? 'not installed'}`];
|
|
23
|
+
|
|
24
|
+
const gitHookInstaller = async (root) => {
|
|
25
|
+
const module = await import(pathToFileURL(path.join(root, 'scripts', 'guards', 'git-hooks.mjs')).href);
|
|
26
|
+
if (typeof module.installGitHooks !== 'function') throw new Error('scripts/guards/git-hooks.mjs does not export installGitHooks');
|
|
27
|
+
return module.installGitHooks;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Find the nearest checkout or installed .claude runtime at or above cwd. */
|
|
31
|
+
export function findRuntimeRoot({ cwd = process.cwd(), exists = existsSync } = {}) {
|
|
32
|
+
let directory = path.resolve(cwd);
|
|
33
|
+
for (;;) {
|
|
34
|
+
for (const candidate of [directory, path.join(directory, '.claude')]) {
|
|
35
|
+
if (missingAt(candidate, exists).length === 0) return candidate;
|
|
36
|
+
}
|
|
37
|
+
const parent = path.dirname(directory);
|
|
38
|
+
if (parent === directory) return null;
|
|
39
|
+
directory = parent;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Point the per-user StarCi launcher at a live runtime checkout. */
|
|
44
|
+
export async function linkRuntime({ cwd = process.cwd(), home = os.homedir(), root = null, json = false, quiet = false,
|
|
45
|
+
stdout = process.stdout, stderr = process.stderr } = {}, deps = {}) {
|
|
46
|
+
const exists = deps.exists ?? existsSync;
|
|
47
|
+
const requested = root == null ? null : path.resolve(cwd, root);
|
|
48
|
+
const runtimeRoot = requested ?? findRuntimeRoot({ cwd, exists });
|
|
49
|
+
if (!runtimeRoot) {
|
|
50
|
+
writeTo(stderr, `starci: no StarCi runtime root found upward from "${path.resolve(cwd)}"; missing ${RUNTIME_ROOT_FILES.join(', ')}\n`);
|
|
51
|
+
return 2;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const missing = missingAt(runtimeRoot, exists);
|
|
55
|
+
if (missing.length) {
|
|
56
|
+
writeTo(stderr, `starci: "${runtimeRoot}" is not a StarCi runtime root; missing ${missing.join(', ')}\n`);
|
|
57
|
+
return 2;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const result = writeRuntimeShim({ root: runtimeRoot, home }, deps);
|
|
62
|
+
const installGitHooks = deps.installGitHooks ?? await gitHookInstaller(runtimeRoot);
|
|
63
|
+
const hooks = await installGitHooks({ root: runtimeRoot });
|
|
64
|
+
if (json) writeTo(stdout, `${JSON.stringify(result)}\n`);
|
|
65
|
+
else if (!quiet) writeTo(stdout, `${[result.runtimeJson, result.shim, ...hookLines(hooks)].join('\n')}\n`);
|
|
66
|
+
return 0;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
writeTo(stderr, `starci: cannot link runtime: ${error?.message ?? error}\n`);
|
|
69
|
+
return 1;
|
|
70
|
+
}
|
|
71
|
+
}
|