@starci/cli 1.0.0 → 1.1.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/src/help.mjs CHANGED
@@ -14,10 +14,10 @@ const rows = (entries) => {
14
14
 
15
15
  export function topHelp(catalog, version = null) {
16
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 ?? '']);
17
+ const options = (catalog.global ?? []).map((flag) => [`--${flag.name}` + (flag.type === 'boolean' ? '' : ` <${flag.name}>`), flag.summary ?? '']);
18
18
  const explainUsage = (catalog.commands ?? []).includes('explain') ? [' starci explain <group> <verb>'] : [];
19
19
  return [
20
- `starci${version ? ` ${version}` : ''} - one CLI for apps and the StarCi runtime`,
20
+ `starci${version ? ' ' + version : ''} - one CLI for apps and the StarCi runtime`,
21
21
  '',
22
22
  'Usage: starci <group> <verb> [options]',
23
23
  ' starci help',
@@ -55,7 +55,9 @@ export function verbHelp(catalog, groupName, verbName) {
55
55
  const verb = catalog.groups?.[groupName]?.verbs?.[verbName];
56
56
  if (!verb) return null;
57
57
  const local = (verb.flags ?? []).map((flag) => {
58
- const value = flag.type === 'boolean' ? '' : flag.type === 'enum' ? ` <${(flag.enum ?? []).join('|')}>` : ` <${flag.name}>`;
58
+ let value = ` <${flag.name}>`;
59
+ if (flag.type === 'boolean') value = '';
60
+ else if (flag.type === 'enum') value = ` <${(flag.enum ?? []).join('|')}>`;
59
61
  const required = flag.required ? ' (required)' : '';
60
62
  return [`--${flag.name}${value}`, `${flag.summary ?? ''}${required}`];
61
63
  });
@@ -75,10 +77,7 @@ export function verbHelp(catalog, groupName, verbName) {
75
77
  ].join('\n');
76
78
  }
77
79
 
78
- /** Full verb help plus the retired raw spellings the catalogued verb replaces. */
80
+ /** Full verb help for a catalogued verb; null when the verb is unknown. */
79
81
  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`;
82
+ return verbHelp(catalog, groupName, verbName);
84
83
  }
package/src/main.mjs CHANGED
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import process from 'node:process';
4
4
  import { fileURLToPath, pathToFileURL } from 'node:url';
5
5
  import { spawnSync } from 'node:child_process';
6
- import { locateRuntime, runtimeEntryOf } from './runtime-locate.mjs';
6
+ import { foreignRuntimeNotice, locateRuntime, ownRuntimeRoot, runtimeEntryOf } from './runtime-locate.mjs';
7
7
  import { runtimeEnv } from './shim.mjs';
8
8
 
9
9
  const packageFile = fileURLToPath(new URL('../package.json', import.meta.url));
@@ -63,27 +63,6 @@ const guardFastPath = async (argv, io) => {
63
63
  }
64
64
  };
65
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
66
  const handlerArgs = (validated, command, withFlagsBeforeDashes, { includeQuiet = true } = {}) => {
88
67
  const extra = [];
89
68
  if (validated.global.json === true && command.json !== 'always') extra.push('--json');
@@ -92,118 +71,96 @@ const handlerArgs = (validated, command, withFlagsBeforeDashes, { includeQuiet =
92
71
  return withFlagsBeforeDashes(validated.localArgs, extra);
93
72
  };
94
73
 
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();
74
+ const ioCtx = (io) => ({ io, stdout: io.stdout ?? process.stdout, stderr: io.stderr ?? process.stderr, env: io.env ?? process.env, home: io.home });
121
75
 
76
+ /** Bare `starci`, `help`/`--help`/`-h` and `--version`; null when argv needs the dispatcher. */
77
+ const earlyOut = (argv, { catalog, version, help, stdout }) => {
122
78
  if (argv.length === 0 || (argv.length === 1 && ['help', '--help', '-h'].includes(argv[0]))) {
123
- writeTo(stdout, topHelp(catalog, version));
79
+ writeTo(stdout, help.topHelp(catalog, version));
124
80
  return 0;
125
81
  }
126
82
  if (argv.length === 1 && argv[0] === '--version') {
127
83
  writeTo(stdout, `${version}\n`);
128
84
  return 0;
129
85
  }
86
+ return null;
87
+ };
130
88
 
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}"`);
89
+ /** `starci help [group] [verb]`; null when argv is not a help invocation. */
90
+ const helpDispatch = (split, { catalog, version, help, stdout, stderr }) => {
91
+ if (split.group !== 'help') return null;
92
+ const verbName = split.args.find((token) => !token.startsWith('-'));
93
+ const target = split.verb ? catalog.groups?.[split.verb] : null;
94
+ if (split.verb && !target) return fail(stderr, `unknown group "${split.verb}"`);
95
+ if (verbName && !target.verbs?.[verbName]) return fail(stderr, `unknown verb "${verbName}" for group "${split.verb}"`);
96
+ let text;
97
+ if (!split.verb) text = help.topHelp(catalog, version);
98
+ else if (verbName) text = help.verbHelp(catalog, split.verb, verbName);
99
+ else text = help.groupHelp(catalog, split.verb);
100
+ writeTo(stdout, text);
101
+ return 0;
102
+ };
134
103
 
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));
104
+ /** `starci completion <shell>`; null when argv is not a completion invocation. */
105
+ const completionDispatch = (split, { io, completionFor, completionShells, stdout, stderr }) => {
106
+ if (split.group !== 'completion') return null;
107
+ const shell = split.verb;
108
+ if (split.help && !split.args.length) {
109
+ writeTo(stdout, `Usage: starci completion <${completionShells.join('|')}>\n`);
141
110
  return 0;
142
111
  }
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);
112
+ if (!completionShells.includes(shell) || split.args.length) return fail(stderr, `completion expects one of: ${completionShells.join(', ')}`);
113
+ const text = (io.completionFor ?? completionFor)(shell);
114
+ if (text == null) return fail(stderr, `completion expects one of: ${completionShells.join(', ')}`);
115
+ writeTo(stdout, text);
116
+ return 0;
117
+ };
118
+
119
+ /** `starci explain <group> <verb>`; null when argv is not explain or the catalog lacks the command. */
120
+ const explainDispatch = (split, { catalog, help, stdout, stderr }) => {
121
+ if (split.group !== 'explain' || !(catalog.commands ?? []).includes('explain')) return null;
122
+ if (split.help && !split.verb && !split.args.length) {
123
+ writeTo(stdout, 'Usage: starci explain <group> <verb>\n');
155
124
  return 0;
156
125
  }
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;
126
+ const groupName = split.verb;
127
+ const verbNames = split.args.filter((token) => !token.startsWith('-'));
128
+ if (!groupName || verbNames.length !== 1) return fail(stderr, 'explain expects <group> <verb>');
129
+ const target = catalog.groups?.[groupName];
130
+ if (!target) return fail(stderr, `unknown group "${groupName}"`);
131
+ const verbName = verbNames[0];
132
+ if (!target.verbs?.[verbName]) {
133
+ return fail(stderr, `unknown verb "${verbName}" for group "${groupName}" (available: ${Object.keys(target.verbs ?? {}).join(', ')})`);
173
134
  }
135
+ writeTo(stdout, help.explainHelp(catalog, groupName, verbName));
136
+ return 0;
137
+ };
174
138
 
139
+ /** Resolve group/verb against the catalog (printing help or failing); {command, group} or {code}. */
140
+ const resolveCommand = (split, { catalog, version, help, stdout, stderr }) => {
175
141
  const wantsHelp = split.help;
176
142
  if (!split.group) {
177
- if (wantsHelp) {
178
- writeTo(stdout, topHelp(catalog, version));
179
- return 0;
180
- }
181
- return fail(stderr, 'missing command group');
143
+ if (wantsHelp) { writeTo(stdout, help.topHelp(catalog, version)); return { code: 0 }; }
144
+ return { code: fail(stderr, 'missing command group') };
182
145
  }
183
146
  const group = catalog.groups?.[split.group];
184
- if (!group) return fail(stderr, `unknown group "${split.group}"`);
147
+ if (!group) return { code: fail(stderr, `unknown group "${split.group}"`) };
185
148
  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}"`);
149
+ if (wantsHelp) { writeTo(stdout, help.groupHelp(catalog, split.group)); return { code: 0 }; }
150
+ return { code: fail(stderr, `missing verb for group "${split.group}"`) };
191
151
  }
192
152
  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);
153
+ if (!command) return { code: fail(stderr, `unknown verb "${split.verb}" for group "${split.group}"`) };
154
+ if (wantsHelp) { writeTo(stdout, help.verbHelp(catalog, split.group, split.verb)); return { code: 0 }; }
155
+ return { command, group };
156
+ };
203
157
 
204
- if (split.group === 'runtime' && split.verb === 'install') {
158
+ /** `starci runtime install|link`; null for any other verb. */
159
+ const runtimeVerb = (split, validated, { io, installRuntime, linkRuntime, home, cwd, stdout, stderr }) => {
160
+ if (split.group !== 'runtime') return null;
161
+ if (split.verb === 'install') {
205
162
  const install = io.installRuntime ?? installRuntime;
206
- return await install({
163
+ return install({
207
164
  cwd,
208
165
  ...(home ? { home } : {}),
209
166
  force: validated.values.force === true,
@@ -211,10 +168,9 @@ export async function main(argv = process.argv.slice(2), io = {}) {
211
168
  noBootstrap: validated.values['no-bootstrap'] === true,
212
169
  }, io.runtimeInstallDeps ?? {});
213
170
  }
214
-
215
- if (split.group === 'runtime' && split.verb === 'link') {
171
+ if (split.verb === 'link') {
216
172
  const link = io.linkRuntime ?? linkRuntime;
217
- return await link({
173
+ return link({
218
174
  cwd,
219
175
  ...(home ? { home } : {}),
220
176
  root: validated.values.root ?? null,
@@ -224,26 +180,34 @@ export async function main(argv = process.argv.slice(2), io = {}) {
224
180
  stderr,
225
181
  }, io.runtimeLinkDeps ?? {});
226
182
  }
183
+ return null;
184
+ };
227
185
 
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
- }
186
+ /** A group owned by @starci/hfs runs in-process; null for runtime-owned groups. */
187
+ const hfsDispatch = async (split, group, args, { io, cwd, stdout, stderr }) => {
188
+ if (group.owner !== '@starci/hfs') return null;
189
+ try {
190
+ const hfs = await (io.importHfs ?? importHfs)();
191
+ const entry = hfs.main ?? hfs.default?.main;
192
+ if (typeof entry !== 'function') return fail(stderr, '@starci/hfs does not export main(argv, io)', 1);
193
+ return Number(await entry([split.verb, ...args], {
194
+ ...io,
195
+ cwd,
196
+ stdout: (text) => writeTo(stdout, text),
197
+ stderr: (text) => writeTo(stderr, text),
198
+ })) || 0;
199
+ } catch (error) {
200
+ return fail(stderr, `cannot load @starci/hfs: ${error?.message ?? error}`, 1);
242
201
  }
202
+ };
243
203
 
204
+ /** Spawn the located runtime for the resolved verb; its exit status is the command's. */
205
+ const runRuntime = (split, args, { io, cwd, env, home, stderr }) => {
244
206
  const skipped = [];
245
207
  const located = (io.locateRuntime ?? locateRuntime)({ cwd, env, skipped, ...(home ? { home } : {}) });
246
208
  if (!located) return noRuntime(stderr, split.group, skipped);
209
+ const notice = foreignRuntimeNotice(located, (io.ownRuntimeRoot ?? ownRuntimeRoot)());
210
+ if (notice) writeTo(stderr, notice);
247
211
  const spawn = io.spawn ?? spawnSync;
248
212
  let result;
249
213
  try {
@@ -262,4 +226,47 @@ export async function main(argv = process.argv.slice(2), io = {}) {
262
226
  }
263
227
  if (result?.status == null) return fail(stderr, `the runtime stopped on signal ${result?.signal ?? 'unknown'}`, 1);
264
228
  return result.status;
229
+ };
230
+
231
+ /** The published CLI entry, with I/O and process seams for deterministic specs. */
232
+ export async function main(argv = process.argv.slice(2), io = {}) {
233
+ const guarded = await guardFastPath(argv, io);
234
+ if (guarded !== null) return guarded;
235
+ const [catalogModule, completionModule, helpModule, installModule, linkModule, argsModule] = await Promise.all([
236
+ import('./catalog.generated.mjs'),
237
+ import('./completion.mjs'),
238
+ import('./help.mjs'),
239
+ import('./runtime-install.mjs'),
240
+ import('./runtime-link.mjs'),
241
+ import('./validate-args.mjs'),
242
+ ]);
243
+ const catalog = io.catalog ?? catalogModule.CATALOG;
244
+ const ctx = {
245
+ ...ioCtx(io), catalog, version: io.version ?? cliVersion(), initialCwd: io.cwd ?? process.cwd(), globals: catalog.global ?? [],
246
+ help: helpModule, completionFor: completionModule.completionFor, completionShells: completionModule.completionShells,
247
+ installRuntime: installModule.installRuntime, linkRuntime: linkModule.linkRuntime,
248
+ };
249
+ const { splitCommand, validateArgs, withFlagsBeforeDashes } = argsModule;
250
+
251
+ const early = earlyOut(argv, ctx);
252
+ if (early !== null) return early;
253
+ const split = splitCommand(argv, ctx.globals);
254
+ for (const dispatch of [helpDispatch, completionDispatch, explainDispatch]) {
255
+ const out = dispatch(split, ctx);
256
+ if (out !== null) return out;
257
+ }
258
+ const resolved = resolveCommand(split, ctx);
259
+ if (!resolved.command) return resolved.code;
260
+ const { command, group } = resolved;
261
+
262
+ const validated = validateArgs(split.args, { ...command, group: split.group, verb: split.verb }, ctx.globals);
263
+ if (!validated.ok) return fail(ctx.stderr, validated.error, validated.code);
264
+ const cwd = path.resolve(ctx.initialCwd, validated.global.cwd ?? '.');
265
+ const args = handlerArgs(validated, command, withFlagsBeforeDashes);
266
+
267
+ const runtimeOut = await runtimeVerb(split, validated, { ...ctx, cwd });
268
+ if (runtimeOut !== null) return runtimeOut;
269
+ const hfsOut = await hfsDispatch(split, group, args, { ...ctx, cwd });
270
+ if (hfsOut !== null) return hfsOut;
271
+ return runRuntime(split, args, { ...ctx, cwd });
265
272
  }
@@ -0,0 +1,2 @@
1
+ // GENERATED by scripts/cli/gen-catalog.mjs — do not edit
2
+ export const RUNTIME_VERSION = "1.0.0-alpha.7";
@@ -6,7 +6,8 @@ import { fileURLToPath } from 'node:url';
6
6
  import { spawnSync } from 'node:child_process';
7
7
  import { writeRuntimeShim } from './shim.mjs';
8
8
 
9
- export const RUNTIME_VERSION = '1.0.0-alpha.4';
9
+ import { RUNTIME_VERSION } from './runtime-install.generated.mjs';
10
+ export { RUNTIME_VERSION };
10
11
 
11
12
  const writeTo = (target, text) => {
12
13
  if (typeof target === 'function') target(text);
@@ -19,8 +20,8 @@ const npmEntryCandidates = ({ execPath = process.execPath, env = process.env } =
19
20
  if (typeof env?.npm_execpath === 'string' && path.basename(env.npm_execpath).toLowerCase() === 'npm-cli.js') {
20
21
  candidates.push(path.resolve(env.npm_execpath));
21
22
  }
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'));
23
+ candidates.push(path.join(nodeDirectory, 'node_modules', 'npm', 'bin', 'npm-cli.js'),
24
+ path.join(path.dirname(nodeDirectory), 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'));
24
25
  return [...new Set(candidates)];
25
26
  };
26
27
 
@@ -105,6 +106,7 @@ export function installRuntime({ cwd = process.cwd(), home = os.homedir(), force
105
106
  const installStatus = statusOf(installed, 'runtime installer', report);
106
107
  if (installStatus !== 0) return installStatus;
107
108
 
108
- writeRuntimeShim({ root: runtimeRoot, home, cli: fileURLToPath(new URL('../bin/starci.mjs', import.meta.url)) }, deps);
109
+ // The download cache supplies the installer; runtime commands must read the installed host's config and bindings.
110
+ writeRuntimeShim({ root: path.resolve(cwd, '.claude'), home, cli: fileURLToPath(new URL('../bin/starci.mjs', import.meta.url)) }, deps);
109
111
  return 0;
110
112
  }
@@ -51,4 +51,17 @@ export function locateRuntime({ cwd = process.cwd(), env = process.env, home = o
51
51
  return null;
52
52
  }
53
53
 
54
+ /** The runtime root of the checkout this CLI file lives in, or null when that checkout is not a runtime root (a published install). */
55
+ export const ownRuntimeRoot = ({ exists = existsSync, embeddedRoot = embeddedRuntimeRoot } = {}) => usableRoot(embeddedRoot, exists);
56
+
57
+ /**
58
+ * The one stderr line naming a located runtime that is not the checkout the CLI runs from, or null when they are the same
59
+ * or the CLI lives outside a runtime. The record and STARCI_RUNTIME outrank the checkout, so the mismatch is said, never silent.
60
+ */
61
+ export function foreignRuntimeNotice(located, own) {
62
+ if (!located || !own) return null;
63
+ const same = process.platform === 'win32' ? located.root.toLowerCase() === own.toLowerCase() : located.root === own;
64
+ return same ? null : `starci: running the runtime at ${located.root} (${located.source}), not this checkout's ${own}; set STARCI_RUNTIME=${own} to use the checkout\n`;
65
+ }
66
+
54
67
  export const runtimeEntryOf = runtimeEntry;
package/src/shim.mjs CHANGED
@@ -10,6 +10,12 @@ const wrapperName = (program, platform) => platform === 'win32' ? `${program}.cm
10
10
  const wrapperText = ({ program, platform, node, cli }) => platform === 'win32'
11
11
  ? `@echo off\r\n"${node}" "${cli}" guard raw ${program} -- %*\r\n`
12
12
  : `#!/bin/sh\nexec "${node}" "${cli}" guard raw ${program} -- "$@"\n`;
13
+ // The extensionless launcher bash runs (scripts/lib/guard-command.mjs spells the hook command that names it); on Windows
14
+ // its paths use forward slashes, which bash and node both accept.
15
+ const launcherText = ({ platform, node, cli }) => {
16
+ const spelled = (file) => platform === 'win32' ? file.replaceAll('\\', '/') : file;
17
+ return `#!/bin/sh\nexec "${spelled(node)}" "${spelled(cli)}" "$@"\n`;
18
+ };
13
19
 
14
20
  /** Write the per-user runtime record, StarCi launcher and guarded PATH tool wrappers for that runtime. */
15
21
  export function writeRuntimeShim({ root, home = os.homedir(), cli: launcher = null } = {}, deps = {}) {
@@ -29,14 +35,16 @@ export function writeRuntimeShim({ root, home = os.homedir(), cli: launcher = nu
29
35
  if (platform === 'win32') {
30
36
  const shim = path.join(shimDir, 'starci.cmd');
31
37
  write(shim, `@echo off\r\n"${node}" "${cli}" %*\r\n`);
38
+ const posixShim = path.join(shimDir, 'starci');
39
+ write(posixShim, launcherText({ platform, node, cli }));
32
40
  for (const program of TOOL_WRAPPERS) {
33
41
  write(path.join(shimDir, wrapperName(program, platform)), wrapperText({ program, platform, node, cli }));
34
42
  }
35
- return { root: runtimeRoot, runtimeJson, shim };
43
+ return { root: runtimeRoot, runtimeJson, shim, posixShim };
36
44
  }
37
45
 
38
46
  const shim = path.join(shimDir, 'starci');
39
- write(shim, `#!/bin/sh\nexec "${node}" "${cli}" "$@"\n`);
47
+ write(shim, launcherText({ platform, node, cli }));
40
48
  chmod(shim, 0o755);
41
49
  for (const program of TOOL_WRAPPERS) {
42
50
  const wrapper = path.join(shimDir, wrapperName(program, platform));
@@ -31,6 +31,84 @@ const typedValue = (flag, raw) => {
31
31
  return raw;
32
32
  };
33
33
 
34
+ /** One `--name[=value]` token: type-check it into values/global, or pass it through to localArgs. */
35
+ const applyOption = (argv, index, token, { known, values, global, localArgs }) => {
36
+ const name = optionName(token);
37
+ const flag = known.get(name);
38
+ if (!flag) throw new Error(`unknown option --${name}`);
39
+ const { value: raw, consumed } = optionValue(argv, index, flag, known);
40
+ const value = typedValue(flag, raw);
41
+ if (flag.type === 'list') {
42
+ values[name] = [...(values[name] ?? []), value];
43
+ } else if (Object.hasOwn(values, name)) {
44
+ throw new Error(`--${name} may be given only once`);
45
+ } else {
46
+ values[name] = value;
47
+ }
48
+ if (flag.global) { global[name] = values[name]; }
49
+ else {
50
+ localArgs.push(token);
51
+ if (consumed) localArgs.push(argv[index + consumed]);
52
+ }
53
+ return consumed;
54
+ };
55
+
56
+ /** Every required local option must have been given. */
57
+ const requiredCheck = (locals, values) => {
58
+ for (const flag of locals.values()) {
59
+ if (flag.required && !Object.hasOwn(values, flag.name)) throw new Error(`missing required option --${flag.name}`);
60
+ }
61
+ };
62
+
63
+ /** The values one positional slot holds: a variadic slot's list, a single value, or none. */
64
+ const candidatesOf = (supplied) => {
65
+ if (Array.isArray(supplied)) return supplied;
66
+ return supplied === undefined ? [] : [supplied];
67
+ };
68
+
69
+ /** A slot with an enum accepts only its members. */
70
+ const checkEnum = (schema, candidates) => {
71
+ for (const candidate of candidates) {
72
+ if (schema.enum && !schema.enum.includes(candidate)) throw new Error(`${schema.name} expects one of: ${schema.enum.join(', ')}`);
73
+ }
74
+ };
75
+
76
+ /** Positional arity and enum checks; passthrough tokens fill free slots first. */
77
+ const checkPositionals = (verb, positionals, passthrough) => {
78
+ const positionalSchema = verb.positional ?? [];
79
+ const variadicAt = positionalSchema.findIndex((entry) => entry.variadic === true);
80
+ const maximum = variadicAt >= 0 ? Infinity : positionalSchema.length;
81
+ if (positionals.length > maximum) throw new Error(`too many positional arguments for ${verb.verb ?? 'command'}`);
82
+ positionals.push(...passthrough.slice(0, Math.max(0, maximum - positionals.length)));
83
+ for (const [index, schema] of positionalSchema.entries()) {
84
+ const supplied = variadicAt === index ? positionals.slice(index) : positionals[index];
85
+ const empty = Array.isArray(supplied) ? supplied.length === 0 : supplied === undefined;
86
+ if (schema.required && empty) throw new Error(`missing required positional ${schema.name}`);
87
+ checkEnum(schema, candidatesOf(supplied));
88
+ }
89
+ };
90
+
91
+ /** Edition availability, --cwd emptiness and --json support. */
92
+ const checkGlobals = (verb, global) => {
93
+ if (global.edition !== undefined && Array.isArray(verb.editions) && !verb.editions.includes(global.edition)) {
94
+ throw new Error(`${verb.group ?? 'this command'} ${verb.verb ?? ''}`.trim() + ` is not available in the ${global.edition} edition (editions: ${verb.editions.join(', ')})`);
95
+ }
96
+ if (global.cwd === '') throw new Error('--cwd needs a value');
97
+ if (global.json === true && verb.json === 'none') {
98
+ throw new Error(`${verb.group ?? 'this command'} ${verb.verb ?? ''}`.trim() + ' has no machine output');
99
+ }
100
+ };
101
+
102
+ /** args = supplied locals, else their declared defaults (a list flag's scalar default wraps in an array). */
103
+ const defaultArgs = (locals, values) => {
104
+ const args = {};
105
+ for (const flag of locals.values()) {
106
+ if (Object.hasOwn(values, flag.name)) args[flag.name] = values[flag.name];
107
+ else if (flag.default !== undefined) args[flag.name] = flag.type === 'list' && !Array.isArray(flag.default) ? [flag.default] : flag.default;
108
+ }
109
+ return args;
110
+ };
111
+
34
112
  /**
35
113
  * Validate the arguments after a catalog group and verb.
36
114
  *
@@ -64,24 +142,7 @@ export function validateArgs(argv, verb, globalFlags = []) {
64
142
  continue;
65
143
  }
66
144
  if (token.startsWith('--')) {
67
- const name = optionName(token);
68
- const flag = known.get(name);
69
- if (!flag) throw new Error(`unknown option --${name}`);
70
- const { value: raw, consumed } = optionValue(argv, index, flag, known);
71
- const value = typedValue(flag, raw);
72
- index += consumed;
73
- if (flag.type === 'list') {
74
- values[name] = [...(values[name] ?? []), value];
75
- } else if (Object.hasOwn(values, name)) {
76
- throw new Error(`--${name} may be given only once`);
77
- } else {
78
- values[name] = value;
79
- }
80
- if (flag.global) global[name] = values[name];
81
- else {
82
- localArgs.push(token);
83
- if (consumed) localArgs.push(argv[index]);
84
- }
145
+ index += applyOption(argv, index, token, { known, values, global, localArgs });
85
146
  continue;
86
147
  }
87
148
  if (token.startsWith('-')) throw new Error(`unknown option ${token}`);
@@ -89,41 +150,10 @@ export function validateArgs(argv, verb, globalFlags = []) {
89
150
  localArgs.push(token);
90
151
  }
91
152
 
92
- for (const flag of locals.values()) {
93
- if (flag.required && !Object.hasOwn(values, flag.name)) throw new Error(`missing required option --${flag.name}`);
94
- }
95
-
96
- const positionalSchema = verb.positional ?? [];
97
- const variadicAt = positionalSchema.findIndex((entry) => entry.variadic === true);
98
- const maximum = variadicAt >= 0 ? Infinity : positionalSchema.length;
99
- if (positionals.length > maximum) throw new Error(`too many positional arguments for ${verb.verb ?? 'command'}`);
100
- positionals.push(...passthrough.slice(0, Math.max(0, maximum - positionals.length)));
101
- for (let index = 0; index < positionalSchema.length; index += 1) {
102
- const schema = positionalSchema[index];
103
- const supplied = variadicAt === index ? positionals.slice(index) : positionals[index];
104
- const empty = Array.isArray(supplied) ? supplied.length === 0 : supplied === undefined;
105
- if (schema.required && empty) throw new Error(`missing required positional ${schema.name}`);
106
- const candidates = Array.isArray(supplied) ? supplied : supplied === undefined ? [] : [supplied];
107
- for (const candidate of candidates) {
108
- if (schema.enum && !schema.enum.includes(candidate)) {
109
- throw new Error(`${schema.name} expects one of: ${schema.enum.join(', ')}`);
110
- }
111
- }
112
- }
113
-
114
- if (global.edition !== undefined && Array.isArray(verb.editions) && !verb.editions.includes(global.edition)) {
115
- throw new Error(`${verb.group ?? 'this command'} ${verb.verb ?? ''}`.trim() + ` is not available in the ${global.edition} edition (editions: ${verb.editions.join(', ')})`);
116
- }
117
- if (global.cwd === '') throw new Error('--cwd needs a value');
118
- if (global.json === true && verb.json === 'none') {
119
- throw new Error(`${verb.group ?? 'this command'} ${verb.verb ?? ''}`.trim() + ' has no machine output');
120
- }
121
- const args = {};
122
- for (const flag of locals.values()) {
123
- if (Object.hasOwn(values, flag.name)) args[flag.name] = values[flag.name];
124
- else if (flag.default !== undefined) args[flag.name] = flag.type === 'list' && !Array.isArray(flag.default) ? [flag.default] : flag.default;
125
- }
126
- return { ok: true, values, args, global, localArgs, positionals };
153
+ requiredCheck(locals, values);
154
+ checkPositionals(verb, positionals, passthrough);
155
+ checkGlobals(verb, global);
156
+ return { ok: true, values, args: defaultArgs(locals, values), global, localArgs, positionals };
127
157
  } catch (error) {
128
158
  return { ok: false, code: 2, error: String(error?.message ?? error) };
129
159
  }