@starci/cli 1.0.1 → 1.2.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,8 @@ 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 { retargetNotice, targetOf, treeTargetRefusal } from './runtime-target.mjs';
7
+ import { foreignRuntimeNotice, locateRuntime, ownRuntimeRoot, runtimeEntryOf } from './runtime-locate.mjs';
7
8
  import { runtimeEnv } from './shim.mjs';
8
9
 
9
10
  const packageFile = fileURLToPath(new URL('../package.json', import.meta.url));
@@ -63,27 +64,6 @@ const guardFastPath = async (argv, io) => {
63
64
  }
64
65
  };
65
66
 
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
67
  const handlerArgs = (validated, command, withFlagsBeforeDashes, { includeQuiet = true } = {}) => {
88
68
  const extra = [];
89
69
  if (validated.global.json === true && command.json !== 'always') extra.push('--json');
@@ -92,118 +72,96 @@ const handlerArgs = (validated, command, withFlagsBeforeDashes, { includeQuiet =
92
72
  return withFlagsBeforeDashes(validated.localArgs, extra);
93
73
  };
94
74
 
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();
75
+ const ioCtx = (io) => ({ io, stdout: io.stdout ?? process.stdout, stderr: io.stderr ?? process.stderr, env: io.env ?? process.env, home: io.home });
121
76
 
77
+ /** Bare `starci`, `help`/`--help`/`-h` and `--version`; null when argv needs the dispatcher. */
78
+ const earlyOut = (argv, { catalog, version, help, stdout }) => {
122
79
  if (argv.length === 0 || (argv.length === 1 && ['help', '--help', '-h'].includes(argv[0]))) {
123
- writeTo(stdout, topHelp(catalog, version));
80
+ writeTo(stdout, help.topHelp(catalog, version));
124
81
  return 0;
125
82
  }
126
83
  if (argv.length === 1 && argv[0] === '--version') {
127
84
  writeTo(stdout, `${version}\n`);
128
85
  return 0;
129
86
  }
87
+ return null;
88
+ };
130
89
 
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}"`);
90
+ /** `starci help [group] [verb]`; null when argv is not a help invocation. */
91
+ const helpDispatch = (split, { catalog, version, help, stdout, stderr }) => {
92
+ if (split.group !== 'help') return null;
93
+ const verbName = split.args.find((token) => !token.startsWith('-'));
94
+ const target = split.verb ? catalog.groups?.[split.verb] : null;
95
+ if (split.verb && !target) return fail(stderr, `unknown group "${split.verb}"`);
96
+ if (verbName && !target.verbs?.[verbName]) return fail(stderr, `unknown verb "${verbName}" for group "${split.verb}"`);
97
+ let text;
98
+ if (!split.verb) text = help.topHelp(catalog, version);
99
+ else if (verbName) text = help.verbHelp(catalog, split.verb, verbName);
100
+ else text = help.groupHelp(catalog, split.verb);
101
+ writeTo(stdout, text);
102
+ return 0;
103
+ };
134
104
 
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));
105
+ /** `starci completion <shell>`; null when argv is not a completion invocation. */
106
+ const completionDispatch = (split, { io, completionFor, completionShells, stdout, stderr }) => {
107
+ if (split.group !== 'completion') return null;
108
+ const shell = split.verb;
109
+ if (split.help && !split.args.length) {
110
+ writeTo(stdout, `Usage: starci completion <${completionShells.join('|')}>\n`);
141
111
  return 0;
142
112
  }
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);
113
+ if (!completionShells.includes(shell) || split.args.length) return fail(stderr, `completion expects one of: ${completionShells.join(', ')}`);
114
+ const text = (io.completionFor ?? completionFor)(shell);
115
+ if (text == null) return fail(stderr, `completion expects one of: ${completionShells.join(', ')}`);
116
+ writeTo(stdout, text);
117
+ return 0;
118
+ };
119
+
120
+ /** `starci explain <group> <verb>`; null when argv is not explain or the catalog lacks the command. */
121
+ const explainDispatch = (split, { catalog, help, stdout, stderr }) => {
122
+ if (split.group !== 'explain' || !(catalog.commands ?? []).includes('explain')) return null;
123
+ if (split.help && !split.verb && !split.args.length) {
124
+ writeTo(stdout, 'Usage: starci explain <group> <verb>\n');
155
125
  return 0;
156
126
  }
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;
127
+ const groupName = split.verb;
128
+ const verbNames = split.args.filter((token) => !token.startsWith('-'));
129
+ if (!groupName || verbNames.length !== 1) return fail(stderr, 'explain expects <group> <verb>');
130
+ const target = catalog.groups?.[groupName];
131
+ if (!target) return fail(stderr, `unknown group "${groupName}"`);
132
+ const verbName = verbNames[0];
133
+ if (!target.verbs?.[verbName]) {
134
+ return fail(stderr, `unknown verb "${verbName}" for group "${groupName}" (available: ${Object.keys(target.verbs ?? {}).join(', ')})`);
173
135
  }
136
+ writeTo(stdout, help.explainHelp(catalog, groupName, verbName));
137
+ return 0;
138
+ };
174
139
 
140
+ /** Resolve group/verb against the catalog (printing help or failing); {command, group} or {code}. */
141
+ const resolveCommand = (split, { catalog, version, help, stdout, stderr }) => {
175
142
  const wantsHelp = split.help;
176
143
  if (!split.group) {
177
- if (wantsHelp) {
178
- writeTo(stdout, topHelp(catalog, version));
179
- return 0;
180
- }
181
- return fail(stderr, 'missing command group');
144
+ if (wantsHelp) { writeTo(stdout, help.topHelp(catalog, version)); return { code: 0 }; }
145
+ return { code: fail(stderr, 'missing command group') };
182
146
  }
183
147
  const group = catalog.groups?.[split.group];
184
- if (!group) return fail(stderr, `unknown group "${split.group}"`);
148
+ if (!group) return { code: fail(stderr, `unknown group "${split.group}"`) };
185
149
  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}"`);
150
+ if (wantsHelp) { writeTo(stdout, help.groupHelp(catalog, split.group)); return { code: 0 }; }
151
+ return { code: fail(stderr, `missing verb for group "${split.group}"`) };
191
152
  }
192
153
  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);
154
+ if (!command) return { code: fail(stderr, `unknown verb "${split.verb}" for group "${split.group}"`) };
155
+ if (wantsHelp) { writeTo(stdout, help.verbHelp(catalog, split.group, split.verb)); return { code: 0 }; }
156
+ return { command, group };
157
+ };
203
158
 
204
- if (split.group === 'runtime' && split.verb === 'install') {
159
+ /** `starci runtime install|link`; null for any other verb. */
160
+ const runtimeVerb = (split, validated, { io, installRuntime, linkRuntime, home, cwd, stdout, stderr }) => {
161
+ if (split.group !== 'runtime') return null;
162
+ if (split.verb === 'install') {
205
163
  const install = io.installRuntime ?? installRuntime;
206
- return await install({
164
+ return install({
207
165
  cwd,
208
166
  ...(home ? { home } : {}),
209
167
  force: validated.values.force === true,
@@ -211,10 +169,9 @@ export async function main(argv = process.argv.slice(2), io = {}) {
211
169
  noBootstrap: validated.values['no-bootstrap'] === true,
212
170
  }, io.runtimeInstallDeps ?? {});
213
171
  }
214
-
215
- if (split.group === 'runtime' && split.verb === 'link') {
172
+ if (split.verb === 'link') {
216
173
  const link = io.linkRuntime ?? linkRuntime;
217
- return await link({
174
+ return link({
218
175
  cwd,
219
176
  ...(home ? { home } : {}),
220
177
  root: validated.values.root ?? null,
@@ -224,26 +181,39 @@ export async function main(argv = process.argv.slice(2), io = {}) {
224
181
  stderr,
225
182
  }, io.runtimeLinkDeps ?? {});
226
183
  }
184
+ return null;
185
+ };
227
186
 
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
- }
187
+ /** A group owned by @starci/hfs runs in-process; null for runtime-owned groups. */
188
+ const hfsDispatch = async (split, group, args, { io, cwd, stdout, stderr }) => {
189
+ if (group.owner !== '@starci/hfs') return null;
190
+ try {
191
+ const hfs = await (io.importHfs ?? importHfs)();
192
+ const entry = hfs.main ?? hfs.default?.main;
193
+ if (typeof entry !== 'function') return fail(stderr, '@starci/hfs does not export main(argv, io)', 1);
194
+ return Number(await entry([split.verb, ...args], {
195
+ ...io,
196
+ cwd,
197
+ stdout: (text) => writeTo(stdout, text),
198
+ stderr: (text) => writeTo(stderr, text),
199
+ })) || 0;
200
+ } catch (error) {
201
+ return fail(stderr, `cannot load @starci/hfs: ${error?.message ?? error}`, 1);
242
202
  }
203
+ };
243
204
 
205
+ /** Spawn the located runtime for the resolved verb; its exit status is the command's. */
206
+ const runRuntime = (split, args, { io, cwd, env, home, stderr, command }) => {
244
207
  const skipped = [];
245
- const located = (io.locateRuntime ?? locateRuntime)({ cwd, env, skipped, ...(home ? { home } : {}) });
246
- if (!located) return noRuntime(stderr, split.group, skipped);
208
+ const record = (io.locateRuntime ?? locateRuntime)({ cwd, env, skipped, ...(home ? { home } : {}) });
209
+ if (!record) return noRuntime(stderr, split.group, skipped);
210
+ const own = (io.ownRuntimeRoot ?? ownRuntimeRoot)();
211
+ const { located, retargeted } = targetOf({ command, args, located: record, own });
212
+ if (retargeted) writeTo(stderr, retargetNotice(retargeted, own));
213
+ const refused = treeTargetRefusal({ command, args, located, own });
214
+ if (refused) return fail(stderr, refused.message, 2);
215
+ const notice = foreignRuntimeNotice(located, own);
216
+ if (notice) writeTo(stderr, notice);
247
217
  const spawn = io.spawn ?? spawnSync;
248
218
  let result;
249
219
  try {
@@ -262,4 +232,47 @@ export async function main(argv = process.argv.slice(2), io = {}) {
262
232
  }
263
233
  if (result?.status == null) return fail(stderr, `the runtime stopped on signal ${result?.signal ?? 'unknown'}`, 1);
264
234
  return result.status;
235
+ };
236
+
237
+ /** The published CLI entry, with I/O and process seams for deterministic specs. */
238
+ export async function main(argv = process.argv.slice(2), io = {}) {
239
+ const guarded = await guardFastPath(argv, io);
240
+ if (guarded !== null) return guarded;
241
+ const [catalogModule, completionModule, helpModule, installModule, linkModule, argsModule] = await Promise.all([
242
+ import('./catalog.generated.mjs'),
243
+ import('./completion.mjs'),
244
+ import('./help.mjs'),
245
+ import('./runtime-install.mjs'),
246
+ import('./runtime-link.mjs'),
247
+ import('./validate-args.mjs'),
248
+ ]);
249
+ const catalog = io.catalog ?? catalogModule.CATALOG;
250
+ const ctx = {
251
+ ...ioCtx(io), catalog, version: io.version ?? cliVersion(), initialCwd: io.cwd ?? process.cwd(), globals: catalog.global ?? [],
252
+ help: helpModule, completionFor: completionModule.completionFor, completionShells: completionModule.completionShells,
253
+ installRuntime: installModule.installRuntime, linkRuntime: linkModule.linkRuntime,
254
+ };
255
+ const { splitCommand, validateArgs, withFlagsBeforeDashes } = argsModule;
256
+
257
+ const early = earlyOut(argv, ctx);
258
+ if (early !== null) return early;
259
+ const split = splitCommand(argv, ctx.globals);
260
+ for (const dispatch of [helpDispatch, completionDispatch, explainDispatch]) {
261
+ const out = dispatch(split, ctx);
262
+ if (out !== null) return out;
263
+ }
264
+ const resolved = resolveCommand(split, ctx);
265
+ if (!resolved.command) return resolved.code;
266
+ const { command, group } = resolved;
267
+
268
+ const validated = validateArgs(split.args, { ...command, group: split.group, verb: split.verb }, ctx.globals);
269
+ if (!validated.ok) return fail(ctx.stderr, validated.error, validated.code);
270
+ const cwd = path.resolve(ctx.initialCwd, validated.global.cwd ?? '.');
271
+ const args = handlerArgs(validated, command, withFlagsBeforeDashes);
272
+
273
+ const runtimeOut = await runtimeVerb(split, validated, { ...ctx, cwd });
274
+ if (runtimeOut !== null) return runtimeOut;
275
+ const hfsOut = await hfsDispatch(split, group, args, { ...ctx, cwd });
276
+ if (hfsOut !== null) return hfsOut;
277
+ return runRuntime(split, args, { ...ctx, cwd, command });
265
278
  }
@@ -1,2 +1,2 @@
1
1
  // GENERATED by scripts/cli/gen-catalog.mjs — do not edit
2
- export const RUNTIME_VERSION = "1.0.0-alpha.4";
2
+ export const RUNTIME_VERSION = "1.0.0-alpha.9";
@@ -20,8 +20,8 @@ const npmEntryCandidates = ({ execPath = process.execPath, env = process.env } =
20
20
  if (typeof env?.npm_execpath === 'string' && path.basename(env.npm_execpath).toLowerCase() === 'npm-cli.js') {
21
21
  candidates.push(path.resolve(env.npm_execpath));
22
22
  }
23
- candidates.push(path.join(nodeDirectory, 'node_modules', 'npm', 'bin', 'npm-cli.js'));
24
- 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'));
25
25
  return [...new Set(candidates)];
26
26
  };
27
27
 
@@ -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;
@@ -0,0 +1,41 @@
1
+ import path from 'node:path';
2
+
3
+ /** The code a verb that writes the runtime tree is refused with when it would write a tree other than the CLI's own. */
4
+ export const RUNTIME_TREE_FOREIGN = 'RUNTIME_TREE_TARGET_FOREIGN';
5
+
6
+ const sameRoot = (a, b) => (process.platform === 'win32' ? path.resolve(a).toLowerCase() === path.resolve(b).toLowerCase() : path.resolve(a) === path.resolve(b));
7
+
8
+ /** Whether this invocation of `command` writes the runtime tree: its `writesRuntimeTree` is true (always), a flag name that makes it write, or `!flag` (writes unless that flag is given). */
9
+ function writesTree(command, args) {
10
+ const mark = command?.writesRuntimeTree;
11
+ if (mark === true) return true;
12
+ if (typeof mark !== 'string') return false;
13
+ return mark.startsWith('!') ? !args.includes(`--${mark.slice(1)}`) : args.includes(`--${mark}`);
14
+ }
15
+
16
+ /**
17
+ * The refusal of a verb that writes the runtime tree while the located runtime is not the tree that owns the running CLI and nothing chose it
18
+ * explicitly (STARCI_RUNTIME, or the verb's own --root). The per-user record outranks the checkout for reading, so a clone's bin would otherwise
19
+ * write the live tree. Null when the verb does not write the tree, the CLI is not in a runtime, or the target is explicit or the CLI's own. Pure.
20
+ */
21
+ export function treeTargetRefusal({ command, args, located, own }) {
22
+ if (!located || !own || !writesTree(command, args)) return null;
23
+ if (located.source === 'STARCI_RUNTIME' || args.includes('--root') || sameRoot(located.root, own)) return null;
24
+ return { code: RUNTIME_TREE_FOREIGN, message: `${RUNTIME_TREE_FOREIGN}: this verb writes the runtime tree, and the located runtime ${located.root} (${located.source}) is not the tree ${own} that owns this CLI; set STARCI_RUNTIME=${own} (or pass --root) to say which tree to write` };
25
+ }
26
+
27
+ /** The explicit runtime choices that outrank a verb's side: STARCI_RUNTIME, or the verb's own --root. */
28
+ const chosenExplicitly = (located, args) => located.source === 'STARCI_RUNTIME' || args.includes('--root');
29
+
30
+ /**
31
+ * The runtime a verb runs: a tree-side verb (one whose result is about the runtime tree that owns the running bin: a check, a test selection, a receipt, a catalog
32
+ * regeneration) runs THIS checkout unless a tree was chosen explicitly, so its verdict is about the tree it ran in; a host-side verb runs the located runtime (the
33
+ * per-user record included). {located, retargeted}: `retargeted` carries the runtime the record pointed at when this checkout replaced it. Pure.
34
+ */
35
+ export function targetOf({ command, args, located, own }) {
36
+ if (!located || !own || command?.runtimeSide !== 'tree' || chosenExplicitly(located, args) || sameRoot(located.root, own)) return { located, retargeted: null };
37
+ return { located: { root: own, source: 'own-checkout' }, retargeted: located };
38
+ }
39
+
40
+ /** The one stderr line saying a tree-side verb ran this checkout although the located runtime is another tree. */
41
+ export const retargetNotice = (retargeted, own) => `starci: this verb is about the runtime tree that owns this CLI, so it ran ${own}, not ${retargeted.root} (${retargeted.source}); set STARCI_RUNTIME to judge another tree\n`;
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));