@moxxy/sdk 0.38.0 → 0.40.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/README.md +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/process-invocation.d.ts +52 -0
- package/dist/process-invocation.d.ts.map +1 -0
- package/dist/process-invocation.js +172 -0
- package/dist/process-invocation.js.map +1 -0
- package/dist/provider-login-bridge.d.ts +11 -0
- package/dist/provider-login-bridge.d.ts.map +1 -1
- package/dist/provider-login-bridge.js +51 -1
- package/dist/provider-login-bridge.js.map +1 -1
- package/dist/provider.d.ts +27 -0
- package/dist/provider.d.ts.map +1 -1
- package/dist/server.d.ts +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1 -0
- package/dist/server.js.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +2 -0
- package/src/process-invocation.test.ts +281 -0
- package/src/process-invocation.ts +237 -0
- package/src/provider-login-bridge.test.ts +42 -0
- package/src/provider-login-bridge.ts +60 -2
- package/src/provider.ts +28 -0
- package/src/server.ts +11 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import * as path from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
executableCandidates,
|
|
17
|
+
execExecutableTargetSync,
|
|
18
|
+
findExecutable,
|
|
19
|
+
resolveExecutableTarget,
|
|
20
|
+
spawnExecutableTarget,
|
|
21
|
+
} from './process-invocation.js';
|
|
22
|
+
|
|
23
|
+
let root: string;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
root = mkdtempSync(path.join(tmpdir(), 'moxxy-process-invocation-'));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
rmSync(root, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
function put(relative: string, body = ''): string {
|
|
34
|
+
const file = path.join(root, relative);
|
|
35
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
36
|
+
writeFileSync(file, body);
|
|
37
|
+
chmodSync(file, 0o755);
|
|
38
|
+
return file;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('executableCandidates', () => {
|
|
42
|
+
it('uses PATHEXT order on Windows and never selects an extensionless Unix shim', () => {
|
|
43
|
+
expect(executableCandidates('moxxy', 'win32', '.EXE;.CMD;.PS1')).toEqual([
|
|
44
|
+
'moxxy.exe',
|
|
45
|
+
'moxxy.cmd',
|
|
46
|
+
'moxxy.ps1',
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('keeps a caller-supplied extension verbatim and uses bare names off Windows', () => {
|
|
51
|
+
expect(executableCandidates('npm.cmd', 'win32', '.EXE;.CMD')).toEqual(['npm.cmd']);
|
|
52
|
+
expect(executableCandidates('npm', 'linux')).toEqual(['npm']);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('findExecutable', () => {
|
|
57
|
+
it('ignores an extensionless Windows shim and finds the first PATHEXT candidate', () => {
|
|
58
|
+
put('moxxy', 'a Unix npm shim');
|
|
59
|
+
const command = put('moxxy.cmd', '@echo off');
|
|
60
|
+
|
|
61
|
+
expect(
|
|
62
|
+
findExecutable('moxxy', [], {
|
|
63
|
+
platform: 'win32',
|
|
64
|
+
pathEnv: root,
|
|
65
|
+
pathext: '.EXE;.CMD',
|
|
66
|
+
}),
|
|
67
|
+
).toBe(command);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('resolveExecutableTarget', () => {
|
|
72
|
+
it('maps npm.cmd to npm-cli.js and runs it through Node without a shell', () => {
|
|
73
|
+
put('npm.cmd', '@echo off');
|
|
74
|
+
const npmCli = put(
|
|
75
|
+
path.join('node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
76
|
+
'process.stdout.write(JSON.stringify(process.argv.slice(2)))',
|
|
77
|
+
);
|
|
78
|
+
const args = ['install', '--prefix', path.join(root, 'A&B workspace'), 'pkg@1.2.3'];
|
|
79
|
+
|
|
80
|
+
const target = resolveExecutableTarget('npm', {
|
|
81
|
+
platform: 'win32',
|
|
82
|
+
pathEnv: root,
|
|
83
|
+
pathext: '.CMD',
|
|
84
|
+
nodeCommand: process.execPath,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(target).toEqual({
|
|
88
|
+
kind: 'node',
|
|
89
|
+
command: process.execPath,
|
|
90
|
+
entry: npmCli,
|
|
91
|
+
env: {},
|
|
92
|
+
});
|
|
93
|
+
expect(target).not.toBeNull();
|
|
94
|
+
if (!target) throw new Error('npm target must resolve');
|
|
95
|
+
|
|
96
|
+
const child = spawnSync(
|
|
97
|
+
target.command,
|
|
98
|
+
target.kind === 'node' ? [target.entry, ...args] : args,
|
|
99
|
+
{ encoding: 'utf8', env: process.env },
|
|
100
|
+
);
|
|
101
|
+
expect(child.status).toBe(0);
|
|
102
|
+
expect(JSON.parse(child.stdout)).toEqual(args);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('recognizes an explicitly supplied Windows launcher case-insensitively', () => {
|
|
106
|
+
const shim = put('NPM.CMD', '@echo off');
|
|
107
|
+
const entry = put(path.join('node_modules', 'npm', 'bin', 'npm-cli.js'));
|
|
108
|
+
|
|
109
|
+
expect(
|
|
110
|
+
resolveExecutableTarget(shim, {
|
|
111
|
+
platform: 'win32',
|
|
112
|
+
nodeCommand: process.execPath,
|
|
113
|
+
}),
|
|
114
|
+
).toMatchObject({ kind: 'node', entry });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('maps a global moxxy.cmd to the installed CLI JavaScript entry', () => {
|
|
118
|
+
put('moxxy.cmd', '@echo off');
|
|
119
|
+
const entry = put(path.join('node_modules', '@moxxy', 'cli', 'dist', 'bin.js'));
|
|
120
|
+
|
|
121
|
+
expect(
|
|
122
|
+
resolveExecutableTarget('moxxy', {
|
|
123
|
+
platform: 'win32',
|
|
124
|
+
pathEnv: root,
|
|
125
|
+
pathext: '.CMD',
|
|
126
|
+
nodeCommand: 'C:\\Moxxy\\Moxxy.exe',
|
|
127
|
+
electron: true,
|
|
128
|
+
}),
|
|
129
|
+
).toEqual({
|
|
130
|
+
kind: 'node',
|
|
131
|
+
command: 'C:\\Moxxy\\Moxxy.exe',
|
|
132
|
+
entry,
|
|
133
|
+
env: { ELECTRON_RUN_AS_NODE: '1' },
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('maps a project-local node_modules/.bin/moxxy.cmd to its sibling package', () => {
|
|
138
|
+
const project = path.join(root, 'project');
|
|
139
|
+
const binDir = path.join(project, 'node_modules', '.bin');
|
|
140
|
+
mkdirSync(binDir, { recursive: true });
|
|
141
|
+
writeFileSync(path.join(binDir, 'moxxy.cmd'), '@echo off');
|
|
142
|
+
const entry = path.join(project, 'node_modules', '@moxxy', 'cli', 'dist', 'bin.js');
|
|
143
|
+
mkdirSync(path.dirname(entry), { recursive: true });
|
|
144
|
+
writeFileSync(entry, '');
|
|
145
|
+
|
|
146
|
+
expect(
|
|
147
|
+
resolveExecutableTarget('moxxy', {
|
|
148
|
+
platform: 'win32',
|
|
149
|
+
pathEnv: binDir,
|
|
150
|
+
pathext: '.CMD',
|
|
151
|
+
}),
|
|
152
|
+
).toMatchObject({ kind: 'node', entry });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('maps pnpm.cmd and yarn.cmd to Corepack JavaScript entrypoints', () => {
|
|
156
|
+
put('pnpm.cmd', '@echo off');
|
|
157
|
+
put('yarn.cmd', '@echo off');
|
|
158
|
+
const pnpmEntry = put(path.join('node_modules', 'corepack', 'dist', 'pnpm.js'));
|
|
159
|
+
const yarnEntry = put(path.join('node_modules', 'corepack', 'dist', 'yarn.js'));
|
|
160
|
+
const options = {
|
|
161
|
+
platform: 'win32' as const,
|
|
162
|
+
pathEnv: root,
|
|
163
|
+
pathext: '.CMD',
|
|
164
|
+
nodeCommand: process.execPath,
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
expect(resolveExecutableTarget('pnpm', options)).toMatchObject({
|
|
168
|
+
kind: 'node',
|
|
169
|
+
entry: pnpmEntry,
|
|
170
|
+
});
|
|
171
|
+
expect(resolveExecutableTarget('yarn', options)).toMatchObject({
|
|
172
|
+
kind: 'node',
|
|
173
|
+
entry: yarnEntry,
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('accepts an explicit non-adjacent entrypoint for a known Windows launcher', () => {
|
|
178
|
+
put('pnpm.cmd', '@echo off');
|
|
179
|
+
const entry = put(path.join('tools', 'pnpm', 'bin', 'pnpm.cjs'));
|
|
180
|
+
|
|
181
|
+
expect(
|
|
182
|
+
resolveExecutableTarget('pnpm', {
|
|
183
|
+
platform: 'win32',
|
|
184
|
+
pathEnv: root,
|
|
185
|
+
pathext: '.CMD',
|
|
186
|
+
nodeEntryHint: entry,
|
|
187
|
+
}),
|
|
188
|
+
).toMatchObject({ kind: 'node', entry });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('fails clearly instead of passing an unknown .cmd file through a shell', () => {
|
|
192
|
+
put('custom.cmd', '@echo off');
|
|
193
|
+
|
|
194
|
+
expect(() =>
|
|
195
|
+
resolveExecutableTarget('custom', {
|
|
196
|
+
platform: 'win32',
|
|
197
|
+
pathEnv: root,
|
|
198
|
+
pathext: '.CMD',
|
|
199
|
+
}),
|
|
200
|
+
).toThrow(/refusing to execute unknown Windows command shim.*custom\.cmd/i);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('fails clearly when a known shim has no matching JavaScript entry', () => {
|
|
204
|
+
const shim = put('npm.cmd', '@echo off');
|
|
205
|
+
|
|
206
|
+
expect(() =>
|
|
207
|
+
resolveExecutableTarget('npm', {
|
|
208
|
+
platform: 'win32',
|
|
209
|
+
pathEnv: root,
|
|
210
|
+
pathext: '.CMD',
|
|
211
|
+
}),
|
|
212
|
+
).toThrow(new RegExp(`cannot resolve npm JavaScript entry.*${path.basename(shim)}`, 'i'));
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('runs native Windows executables directly', () => {
|
|
216
|
+
const executable = put('bun.exe');
|
|
217
|
+
|
|
218
|
+
expect(
|
|
219
|
+
resolveExecutableTarget('bun', {
|
|
220
|
+
platform: 'win32',
|
|
221
|
+
pathEnv: root,
|
|
222
|
+
pathext: '.EXE;.CMD',
|
|
223
|
+
}),
|
|
224
|
+
).toEqual({ kind: 'direct', command: executable });
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe('spawnExecutableTarget', () => {
|
|
229
|
+
it('prepends the mapped JavaScript entry and preserves each caller argument', () => {
|
|
230
|
+
const entry = put(
|
|
231
|
+
'cli.js',
|
|
232
|
+
'process.stdout.write(JSON.stringify(process.argv.slice(2)))',
|
|
233
|
+
);
|
|
234
|
+
const target = {
|
|
235
|
+
kind: 'node' as const,
|
|
236
|
+
command: process.execPath,
|
|
237
|
+
entry,
|
|
238
|
+
env: {},
|
|
239
|
+
};
|
|
240
|
+
const args = ['one argument', 'A&B', 'value|still-one-argv'];
|
|
241
|
+
|
|
242
|
+
const child = spawnExecutableTarget(target, args, {
|
|
243
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
244
|
+
});
|
|
245
|
+
let stdout = '';
|
|
246
|
+
child.stdout?.setEncoding('utf8');
|
|
247
|
+
child.stdout?.on('data', (chunk: string) => {
|
|
248
|
+
stdout += chunk;
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
return new Promise<void>((resolve, reject) => {
|
|
252
|
+
child.once('error', reject);
|
|
253
|
+
child.once('close', (code) => {
|
|
254
|
+
try {
|
|
255
|
+
expect(code).toBe(0);
|
|
256
|
+
expect(JSON.parse(stdout)).toEqual(args);
|
|
257
|
+
resolve();
|
|
258
|
+
} catch (error) {
|
|
259
|
+
reject(error);
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('runs a mapped target synchronously while preserving argv boundaries', () => {
|
|
266
|
+
const entry = put(
|
|
267
|
+
'sync-cli.js',
|
|
268
|
+
"require('node:fs').writeFileSync(process.argv[2], JSON.stringify(process.argv.slice(3)))",
|
|
269
|
+
);
|
|
270
|
+
const observed = path.join(root, 'sync-argv.json');
|
|
271
|
+
const args = [observed, 'A&B', 'two words', 'still|one'];
|
|
272
|
+
|
|
273
|
+
execExecutableTargetSync(
|
|
274
|
+
{ kind: 'node', command: process.execPath, entry, env: {} },
|
|
275
|
+
args,
|
|
276
|
+
{ stdio: 'ignore' },
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
expect(JSON.parse(readFileSync(observed, 'utf8'))).toEqual(args.slice(1));
|
|
280
|
+
});
|
|
281
|
+
});
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import {
|
|
2
|
+
execFileSync,
|
|
3
|
+
spawn,
|
|
4
|
+
type ChildProcess,
|
|
5
|
+
type ExecFileSyncOptions,
|
|
6
|
+
type SpawnOptions,
|
|
7
|
+
} from 'node:child_process';
|
|
8
|
+
import { statSync } from 'node:fs';
|
|
9
|
+
import * as path from 'node:path';
|
|
10
|
+
|
|
11
|
+
const DEFAULT_WINDOWS_PATHEXT = '.COM;.EXE;.BAT;.CMD';
|
|
12
|
+
const WINDOWS_NATIVE_EXTENSIONS = new Set(['.com', '.exe']);
|
|
13
|
+
const WINDOWS_SCRIPT_EXTENSIONS = new Set(['.bat', '.cmd', '.ps1']);
|
|
14
|
+
const KNOWN_NODE_CLIS = new Set<KnownNodeCli>(['moxxy', 'npm', 'pnpm', 'yarn']);
|
|
15
|
+
|
|
16
|
+
type KnownNodeCli = 'moxxy' | 'npm' | 'pnpm' | 'yarn';
|
|
17
|
+
|
|
18
|
+
export type ExecutableTarget =
|
|
19
|
+
| { readonly kind: 'direct'; readonly command: string }
|
|
20
|
+
| {
|
|
21
|
+
readonly kind: 'node';
|
|
22
|
+
readonly command: string;
|
|
23
|
+
readonly entry: string;
|
|
24
|
+
readonly env: Readonly<Record<string, string>>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export interface ExecutableSearchOptions {
|
|
28
|
+
readonly extraPaths?: ReadonlyArray<string>;
|
|
29
|
+
readonly platform?: NodeJS.Platform;
|
|
30
|
+
readonly pathEnv?: string;
|
|
31
|
+
readonly pathext?: string;
|
|
32
|
+
readonly nodeCommand?: string;
|
|
33
|
+
/** Trusted JS entrypoint for a known launcher whose package is not adjacent to its shim. */
|
|
34
|
+
readonly nodeEntryHint?: string;
|
|
35
|
+
readonly electron?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Spawn options that cannot opt back into a command shell. */
|
|
39
|
+
export type SafeSpawnOptions = Omit<SpawnOptions, 'shell'> & { readonly shell?: never };
|
|
40
|
+
export type SafeExecFileSyncOptions = Omit<ExecFileSyncOptions, 'shell'> & {
|
|
41
|
+
readonly shell?: never;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Filenames Windows itself considers for a bare command name, in PATHEXT order.
|
|
46
|
+
*
|
|
47
|
+
* The extensionless npm shim is deliberately absent: npm writes a POSIX script
|
|
48
|
+
* beside its Windows launchers, but CreateProcess cannot safely execute it.
|
|
49
|
+
* Off Windows the bare name remains the executable.
|
|
50
|
+
*/
|
|
51
|
+
export function executableCandidates(
|
|
52
|
+
name: string,
|
|
53
|
+
platform: NodeJS.Platform = process.platform,
|
|
54
|
+
pathext: string | undefined = process.env.PATHEXT,
|
|
55
|
+
): string[] {
|
|
56
|
+
if (platform !== 'win32' || path.extname(name)) return [name];
|
|
57
|
+
const seen = new Set<string>();
|
|
58
|
+
const out: string[] = [];
|
|
59
|
+
for (const raw of (pathext ?? DEFAULT_WINDOWS_PATHEXT).split(';')) {
|
|
60
|
+
const trimmed = raw.trim();
|
|
61
|
+
if (!trimmed) continue;
|
|
62
|
+
const extension = (trimmed.startsWith('.') ? trimmed : `.${trimmed}`).toLowerCase();
|
|
63
|
+
if (seen.has(extension)) continue;
|
|
64
|
+
seen.add(extension);
|
|
65
|
+
out.push(`${name}${extension}`);
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Find the first readable file using PATH-directory order, then PATHEXT order. */
|
|
71
|
+
export function findExecutable(
|
|
72
|
+
name: string,
|
|
73
|
+
extraPaths: ReadonlyArray<string> = [],
|
|
74
|
+
opts: ExecutableSearchOptions = {},
|
|
75
|
+
): string | null {
|
|
76
|
+
const platform = opts.platform ?? process.platform;
|
|
77
|
+
if (hasPathSeparator(name, platform)) return isFile(name) ? name : null;
|
|
78
|
+
|
|
79
|
+
const separator = platform === 'win32' ? ';' : path.delimiter;
|
|
80
|
+
const dirs = (opts.pathEnv ?? process.env.PATH ?? '')
|
|
81
|
+
.split(separator)
|
|
82
|
+
.concat(extraPaths)
|
|
83
|
+
.filter(Boolean);
|
|
84
|
+
const candidates = executableCandidates(name, platform, opts.pathext);
|
|
85
|
+
for (const dir of dirs) {
|
|
86
|
+
for (const candidate of candidates) {
|
|
87
|
+
const full = path.join(dir, candidate);
|
|
88
|
+
if (isFile(full)) return full;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Resolve a command without ever delegating argument parsing to a shell.
|
|
96
|
+
*
|
|
97
|
+
* Native executables and POSIX commands run directly. On Windows, supported
|
|
98
|
+
* Node launchers are mapped to their known JS entrypoints and run through the
|
|
99
|
+
* current Node/Electron runtime. Any other Windows script shim fails closed:
|
|
100
|
+
* parsing an arbitrary .cmd/.bat/.ps1 would recreate the shell-injection
|
|
101
|
+
* boundary this helper exists to remove.
|
|
102
|
+
*/
|
|
103
|
+
export function resolveExecutableTarget(
|
|
104
|
+
name: string,
|
|
105
|
+
opts: ExecutableSearchOptions = {},
|
|
106
|
+
): ExecutableTarget | null {
|
|
107
|
+
const platform = opts.platform ?? process.platform;
|
|
108
|
+
const executable = findExecutable(name, opts.extraPaths ?? [], opts);
|
|
109
|
+
if (!executable) return null;
|
|
110
|
+
if (platform !== 'win32') return { kind: 'direct', command: executable };
|
|
111
|
+
|
|
112
|
+
const rawExtension = path.extname(executable);
|
|
113
|
+
const extension = rawExtension.toLowerCase();
|
|
114
|
+
if (WINDOWS_NATIVE_EXTENSIONS.has(extension)) {
|
|
115
|
+
return { kind: 'direct', command: executable };
|
|
116
|
+
}
|
|
117
|
+
if (!WINDOWS_SCRIPT_EXTENSIONS.has(extension)) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`Refusing to execute extensionless Windows command "${executable}". ` +
|
|
120
|
+
'Install a native executable or a supported Node .cmd launcher.',
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
if (extension !== '.cmd') {
|
|
124
|
+
throw unknownWindowsShim(executable);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const commandName = path.basename(executable, rawExtension).toLowerCase();
|
|
128
|
+
if (!isKnownNodeCli(commandName)) {
|
|
129
|
+
throw unknownWindowsShim(executable);
|
|
130
|
+
}
|
|
131
|
+
const entries = opts.nodeEntryHint
|
|
132
|
+
? [opts.nodeEntryHint, ...knownNodeCliEntries(executable, commandName)]
|
|
133
|
+
: knownNodeCliEntries(executable, commandName);
|
|
134
|
+
const entry = entries.find(isFile);
|
|
135
|
+
if (!entry) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`Cannot resolve ${commandName} JavaScript entry beside Windows shim "${executable}". ` +
|
|
138
|
+
'Refusing to run the .cmd file through a shell.',
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const electron = opts.electron ?? Boolean(process.versions.electron);
|
|
143
|
+
return {
|
|
144
|
+
kind: 'node',
|
|
145
|
+
command: opts.nodeCommand ?? process.execPath,
|
|
146
|
+
entry,
|
|
147
|
+
env: electron ? { ELECTRON_RUN_AS_NODE: '1' } : {},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Spawn a resolved target with an argv array and an explicitly shell-free API. */
|
|
152
|
+
export function spawnExecutableTarget(
|
|
153
|
+
target: ExecutableTarget,
|
|
154
|
+
args: ReadonlyArray<string>,
|
|
155
|
+
opts: SafeSpawnOptions = {},
|
|
156
|
+
): ChildProcess {
|
|
157
|
+
const argv = target.kind === 'node' ? [target.entry, ...args] : [...args];
|
|
158
|
+
const targetEnv = target.kind === 'node' ? target.env : {};
|
|
159
|
+
return spawn(target.command, argv, {
|
|
160
|
+
...opts,
|
|
161
|
+
env: { ...process.env, ...opts.env, ...targetEnv },
|
|
162
|
+
shell: false,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Synchronous counterpart for build scripts that must finish one command at a time. */
|
|
167
|
+
export function execExecutableTargetSync(
|
|
168
|
+
target: ExecutableTarget,
|
|
169
|
+
args: ReadonlyArray<string>,
|
|
170
|
+
opts: SafeExecFileSyncOptions = {},
|
|
171
|
+
): void {
|
|
172
|
+
const argv = target.kind === 'node' ? [target.entry, ...args] : [...args];
|
|
173
|
+
const targetEnv = target.kind === 'node' ? target.env : {};
|
|
174
|
+
execFileSync(target.command, argv, {
|
|
175
|
+
...opts,
|
|
176
|
+
env: { ...process.env, ...opts.env, ...targetEnv },
|
|
177
|
+
shell: false,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function knownNodeCliEntries(shim: string, command: KnownNodeCli): string[] {
|
|
182
|
+
const binDir = path.dirname(shim);
|
|
183
|
+
if (command === 'npm') {
|
|
184
|
+
return [
|
|
185
|
+
path.join(binDir, 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
186
|
+
path.join(binDir, '..', 'npm', 'bin', 'npm-cli.js'),
|
|
187
|
+
];
|
|
188
|
+
}
|
|
189
|
+
if (command === 'moxxy') {
|
|
190
|
+
return [
|
|
191
|
+
path.join(binDir, 'node_modules', '@moxxy', 'cli', 'dist', 'bin.js'),
|
|
192
|
+
path.join(binDir, '..', '@moxxy', 'cli', 'dist', 'bin.js'),
|
|
193
|
+
];
|
|
194
|
+
}
|
|
195
|
+
if (command === 'pnpm') {
|
|
196
|
+
return [
|
|
197
|
+
path.join(binDir, 'node_modules', 'corepack', 'dist', 'pnpm.js'),
|
|
198
|
+
path.join(binDir, 'node_modules', 'pnpm', 'bin', 'pnpm.cjs'),
|
|
199
|
+
path.join(binDir, '..', 'corepack', 'dist', 'pnpm.js'),
|
|
200
|
+
path.join(binDir, '..', 'pnpm', 'bin', 'pnpm.cjs'),
|
|
201
|
+
];
|
|
202
|
+
}
|
|
203
|
+
return [
|
|
204
|
+
path.join(binDir, 'node_modules', 'corepack', 'dist', 'yarn.js'),
|
|
205
|
+
path.join(binDir, 'node_modules', 'yarn', 'bin', 'yarn.js'),
|
|
206
|
+
path.join(binDir, 'node_modules', '@yarnpkg', 'cli-dist', 'bin', 'yarn.js'),
|
|
207
|
+
path.join(binDir, '..', 'corepack', 'dist', 'yarn.js'),
|
|
208
|
+
path.join(binDir, '..', 'yarn', 'bin', 'yarn.js'),
|
|
209
|
+
path.join(binDir, '..', '@yarnpkg', 'cli-dist', 'bin', 'yarn.js'),
|
|
210
|
+
];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function isKnownNodeCli(command: string): command is KnownNodeCli {
|
|
214
|
+
return KNOWN_NODE_CLIS.has(command as KnownNodeCli);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function hasPathSeparator(name: string, platform: NodeJS.Platform): boolean {
|
|
218
|
+
return platform === 'win32'
|
|
219
|
+
? name.includes('\\') || name.includes('/')
|
|
220
|
+
: name.includes(path.sep);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function isFile(file: string): boolean {
|
|
224
|
+
try {
|
|
225
|
+
return statSync(file).isFile();
|
|
226
|
+
} catch {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function unknownWindowsShim(executable: string): Error {
|
|
232
|
+
return new Error(
|
|
233
|
+
`Refusing to execute unknown Windows command shim "${executable}". ` +
|
|
234
|
+
'Only moxxy.cmd, npm.cmd, pnpm.cmd, and yarn.cmd are mapped to known JavaScript entrypoints; ' +
|
|
235
|
+
'shell execution is disabled.',
|
|
236
|
+
);
|
|
237
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import {
|
|
3
3
|
createLoginStreamScanner,
|
|
4
|
+
decodeLoginAuthUrl,
|
|
4
5
|
decodeLoginPrompt,
|
|
6
|
+
encodeLoginAuthUrl,
|
|
5
7
|
encodeLoginPrompt,
|
|
6
8
|
type LoginStreamItem,
|
|
7
9
|
} from './provider-login-bridge.js';
|
|
@@ -23,6 +25,35 @@ describe('encode/decode login prompt', () => {
|
|
|
23
25
|
});
|
|
24
26
|
});
|
|
25
27
|
|
|
28
|
+
describe('encode/decode login auth URL', () => {
|
|
29
|
+
const authUrl =
|
|
30
|
+
'https://auth.example.test/authorize?client_id=moxxy&state=opaque&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fcallback';
|
|
31
|
+
|
|
32
|
+
it('round-trips a credential-free http(s) URL', () => {
|
|
33
|
+
const marker = encodeLoginAuthUrl(authUrl);
|
|
34
|
+
expect(marker.startsWith('\u0000')).toBe(true);
|
|
35
|
+
expect(marker.endsWith('\u0000')).toBe(true);
|
|
36
|
+
expect(decodeLoginAuthUrl(marker.slice(1, -1))).toBe(authUrl);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it.each([
|
|
40
|
+
'file:///etc/passwd',
|
|
41
|
+
'javascript:alert(1)',
|
|
42
|
+
'data:text/html,<script>alert(1)</script>',
|
|
43
|
+
'https://alice:secret@auth.example.test/authorize',
|
|
44
|
+
])('rejects an unsafe URL before encoding: %s', (url) => {
|
|
45
|
+
expect(() => encodeLoginAuthUrl(url)).toThrow();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it.each([
|
|
49
|
+
'{"tag":"moxxy.login.auth_url","url":"file:///etc/passwd"}',
|
|
50
|
+
'{"tag":"moxxy.login.auth_url","url":"https://alice:secret@auth.example.test/"}',
|
|
51
|
+
'{"tag":"moxxy.login.auth_url","url":42}',
|
|
52
|
+
])('rejects a forged unsafe marker: %s', (segment) => {
|
|
53
|
+
expect(decodeLoginAuthUrl(segment)).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
26
57
|
/** Drain a scanner across the given chunks into a flat item list. */
|
|
27
58
|
function scanAll(chunks: string[]): LoginStreamItem[] {
|
|
28
59
|
const scanner = createLoginStreamScanner();
|
|
@@ -63,6 +94,17 @@ describe('createLoginStreamScanner', () => {
|
|
|
63
94
|
]);
|
|
64
95
|
});
|
|
65
96
|
|
|
97
|
+
it('reassembles an auth_url marker and keeps it distinct from plain output', () => {
|
|
98
|
+
const url = 'https://auth.example.test/authorize?state=opaque';
|
|
99
|
+
const marker = encodeLoginAuthUrl(url);
|
|
100
|
+
const mid = Math.floor(marker.length / 2);
|
|
101
|
+
expect(scanAll(['opening\n' + marker.slice(0, mid), marker.slice(mid), 'waiting\n'])).toEqual([
|
|
102
|
+
{ type: 'output', text: 'opening\n' },
|
|
103
|
+
{ type: 'auth_url', url },
|
|
104
|
+
{ type: 'output', text: 'waiting\n' },
|
|
105
|
+
]);
|
|
106
|
+
});
|
|
107
|
+
|
|
66
108
|
it('holds back a lone opening NUL until its partner arrives', () => {
|
|
67
109
|
const scanner = createLoginStreamScanner();
|
|
68
110
|
const marker = encodeLoginPrompt({ question: 'q', mask: false });
|
|
@@ -17,12 +17,40 @@
|
|
|
17
17
|
* depend on, so the two ends can never drift.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import { z } from 'zod';
|
|
21
|
+
|
|
20
22
|
/** The byte that brackets a prompt marker. Absent from normal CLI output. */
|
|
21
23
|
const NUL = '\u0000';
|
|
22
24
|
|
|
23
25
|
/** Tag distinguishing a login-prompt marker from any other NUL-bracketed run. */
|
|
24
26
|
const PROMPT_TAG = 'moxxy.login.prompt';
|
|
25
27
|
|
|
28
|
+
/** Tag distinguishing a browser-authorization URL from normal login output. */
|
|
29
|
+
const AUTH_URL_TAG = 'moxxy.login.auth_url';
|
|
30
|
+
|
|
31
|
+
const LoginAuthUrlSchema = z
|
|
32
|
+
.string()
|
|
33
|
+
.max(8192)
|
|
34
|
+
.refine((value) => {
|
|
35
|
+
try {
|
|
36
|
+
const url = new URL(value);
|
|
37
|
+
return (
|
|
38
|
+
(url.protocol === 'http:' || url.protocol === 'https:') &&
|
|
39
|
+
url.username === '' &&
|
|
40
|
+
url.password === ''
|
|
41
|
+
);
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}, 'must be an http(s) URL without embedded credentials');
|
|
46
|
+
|
|
47
|
+
const LoginAuthUrlMarkerSchema = z
|
|
48
|
+
.object({
|
|
49
|
+
tag: z.literal(AUTH_URL_TAG),
|
|
50
|
+
url: LoginAuthUrlSchema,
|
|
51
|
+
})
|
|
52
|
+
.strict();
|
|
53
|
+
|
|
26
54
|
export interface LoginPromptRequest {
|
|
27
55
|
/** The question to show the user, verbatim from `ctx.prompt`. */
|
|
28
56
|
readonly question: string;
|
|
@@ -33,7 +61,8 @@ export interface LoginPromptRequest {
|
|
|
33
61
|
/** One decoded item from a login subprocess's stdout stream. */
|
|
34
62
|
export type LoginStreamItem =
|
|
35
63
|
| { readonly type: 'output'; readonly text: string }
|
|
36
|
-
| { readonly type: 'prompt'; readonly prompt: LoginPromptRequest }
|
|
64
|
+
| { readonly type: 'prompt'; readonly prompt: LoginPromptRequest }
|
|
65
|
+
| { readonly type: 'auth_url'; readonly url: string };
|
|
37
66
|
|
|
38
67
|
/**
|
|
39
68
|
* Serialize a prompt request as a NUL-bracketed marker for stdout. The
|
|
@@ -59,6 +88,28 @@ export function decodeLoginPrompt(segment: string): LoginPromptRequest | null {
|
|
|
59
88
|
}
|
|
60
89
|
}
|
|
61
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Serialize the browser authorization URL for a GUI host. Validation happens
|
|
93
|
+
* before a marker reaches stdout: a provider-controlled URL may only be a
|
|
94
|
+
* credential-free HTTP(S) URL, never a shell-adjacent custom/file scheme.
|
|
95
|
+
*/
|
|
96
|
+
export function encodeLoginAuthUrl(url: string): string {
|
|
97
|
+
const marker = LoginAuthUrlMarkerSchema.parse({ tag: AUTH_URL_TAG, url });
|
|
98
|
+
return NUL + JSON.stringify(marker) + NUL;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Decode and validate one inter-NUL authorization-URL marker. */
|
|
102
|
+
export function decodeLoginAuthUrl(segment: string): string | null {
|
|
103
|
+
if (!segment.startsWith('{')) return null;
|
|
104
|
+
try {
|
|
105
|
+
const parsed: unknown = JSON.parse(segment);
|
|
106
|
+
const marker = LoginAuthUrlMarkerSchema.safeParse(parsed);
|
|
107
|
+
return marker.success ? marker.data.url : null;
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
62
113
|
/**
|
|
63
114
|
* Streaming scanner for a login subprocess's stdout. Feed it chunks; it
|
|
64
115
|
* returns the ordered output / prompt items decoded so far, holding back any
|
|
@@ -84,9 +135,16 @@ export function createLoginStreamScanner(): {
|
|
|
84
135
|
if (open > 0) out.push({ type: 'output', text: buf.slice(0, open) });
|
|
85
136
|
const segment = buf.slice(open + 1, close);
|
|
86
137
|
const prompt = decodeLoginPrompt(segment);
|
|
138
|
+
const authUrl = prompt ? null : decodeLoginAuthUrl(segment);
|
|
87
139
|
// A NUL-bracketed run that isn't a valid marker is anomalous; surface
|
|
88
140
|
// its inner text as output rather than swallowing it.
|
|
89
|
-
out.push(
|
|
141
|
+
out.push(
|
|
142
|
+
prompt
|
|
143
|
+
? { type: 'prompt', prompt }
|
|
144
|
+
: authUrl !== null
|
|
145
|
+
? { type: 'auth_url', url: authUrl }
|
|
146
|
+
: { type: 'output', text: segment },
|
|
147
|
+
);
|
|
90
148
|
buf = buf.slice(close + 1);
|
|
91
149
|
}
|
|
92
150
|
// Flush any plain text that precedes a still-incomplete marker (or all
|