engineering-memory 0.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/bin/engineering-memory.mjs +120 -0
- package/dispatcher/managed-section.mjs +59 -0
- package/dispatcher/sections.mjs +14 -0
- package/install/api-url.mjs +39 -0
- package/install/cli.mjs +93 -0
- package/install/commands.mjs +140 -0
- package/install/files.mjs +416 -0
- package/install/git-hook.mjs +270 -0
- package/install/installer.mjs +279 -0
- package/install/mcp-registration.mjs +457 -0
- package/package.json +28 -0
- package/runtime/dist/src/auth/browser-auth.js +184 -0
- package/runtime/dist/src/auth/credential-store.js +181 -0
- package/runtime/dist/src/cache/etag-cache.js +123 -0
- package/runtime/dist/src/config.js +59 -0
- package/runtime/dist/src/git/git-inspector.js +375 -0
- package/runtime/dist/src/git/pre-commit.js +44 -0
- package/runtime/dist/src/git/verification-gate.js +221 -0
- package/runtime/dist/src/index.js +60 -0
- package/runtime/dist/src/journal/journal-store.js +1300 -0
- package/runtime/dist/src/mcp/server.js +11 -0
- package/runtime/dist/src/mcp/tool-definitions.js +405 -0
- package/runtime/dist/src/project/repository.js +79 -0
- package/runtime/dist/src/runtime/active-context-store.js +356 -0
- package/runtime/dist/src/runtime/api-client.js +229 -0
- package/runtime/dist/src/runtime/bridge-service.js +2226 -0
- package/runtime/dist/src/runtime/offline-outbox.js +274 -0
- package/runtime/dist/src/runtime/principal-state.js +97 -0
- package/runtime/dist/src/types.js +2 -0
- package/runtime/dist/src/utilities/files.js +189 -0
- package/runtime/dist/src/utilities/hash.js +19 -0
- package/runtime/dist/src/utilities/process.js +32 -0
- package/runtime/package-lock.json +137 -0
- package/runtime/package.json +32 -0
- package/skill/SKILL.md +29 -0
- package/skill/agents/openai.yaml +6 -0
- package/skill/references/lifecycle.md +102 -0
- package/skill/references/memory-updates.md +25 -0
- package/skill/references/questionnaires.md +98 -0
- package/skill/references/scaffolding.md +38 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { cp, mkdir, mkdtemp, rm } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import {
|
|
8
|
+
exists,
|
|
9
|
+
readJson,
|
|
10
|
+
resolveProductionPackagePaths,
|
|
11
|
+
} from '../install/files.mjs';
|
|
12
|
+
import { runInstaller } from '../install/cli.mjs';
|
|
13
|
+
|
|
14
|
+
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
|
|
17
|
+
const usage = `Usage:
|
|
18
|
+
engineering-memory install
|
|
19
|
+
|
|
20
|
+
Installs the skill and the local bridge, and points them at the Engineering
|
|
21
|
+
Memory backend this package was published for. Nothing else is needed: you sign
|
|
22
|
+
in the first time an agent uses it, and your organization and project come from
|
|
23
|
+
your account.
|
|
24
|
+
|
|
25
|
+
Options:
|
|
26
|
+
--clients codex,claude which agent CLIs to install for
|
|
27
|
+
--api-url <https-url> a different backend, for a self-hosted install
|
|
28
|
+
--repo <repository-root> install the Git verification gate in this repository
|
|
29
|
+
--hook-mode chain|verify-only|cancel
|
|
30
|
+
--help
|
|
31
|
+
|
|
32
|
+
Choose the hook mode through the Codex or Claude native questionnaire before
|
|
33
|
+
passing a repository. The installer never opens a questionnaire or an
|
|
34
|
+
authentication web page.`;
|
|
35
|
+
|
|
36
|
+
// The published package cannot carry node_modules, because npm excludes it from
|
|
37
|
+
// every tarball. The bridge runtime is therefore assembled here from the
|
|
38
|
+
// dependencies npm installed for this package, into the exact layout the
|
|
39
|
+
// installer verifies against the bridge lockfile.
|
|
40
|
+
async function stageBridgeRuntime() {
|
|
41
|
+
const source = join(packageRoot, 'runtime');
|
|
42
|
+
const packageJson = await readJson(join(source, 'package.json'));
|
|
43
|
+
const packageLock = await readJson(join(source, 'package-lock.json'));
|
|
44
|
+
if (!packageJson || !packageLock) {
|
|
45
|
+
throw new Error(`Bridge runtime metadata is missing in ${source}`);
|
|
46
|
+
}
|
|
47
|
+
const staged = await mkdtemp(join(tmpdir(), 'engineering-memory-runtime-'));
|
|
48
|
+
await cp(join(source, 'dist'), join(staged, 'dist'), { recursive: true });
|
|
49
|
+
await cp(join(source, 'package.json'), join(staged, 'package.json'));
|
|
50
|
+
await cp(
|
|
51
|
+
join(source, 'package-lock.json'),
|
|
52
|
+
join(staged, 'package-lock.json'),
|
|
53
|
+
);
|
|
54
|
+
for (const entry of resolveProductionPackagePaths(packageJson, packageLock)) {
|
|
55
|
+
const name = entry.path.slice('node_modules/'.length);
|
|
56
|
+
let installed;
|
|
57
|
+
try {
|
|
58
|
+
installed = dirname(require.resolve(`${name}/package.json`));
|
|
59
|
+
} catch {
|
|
60
|
+
if (entry.optional) continue;
|
|
61
|
+
throw new Error(
|
|
62
|
+
`Bridge dependency ${name} is not installed. Reinstall this package so npm resolves its dependencies.`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
const destination = join(staged, ...entry.path.split('/'));
|
|
66
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
67
|
+
await cp(installed, destination, { recursive: true });
|
|
68
|
+
}
|
|
69
|
+
return staged;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The backend this package was published for. An installation only names one
|
|
73
|
+
// when it points at a different, self-hosted backend.
|
|
74
|
+
async function publishedApiUrl() {
|
|
75
|
+
const manifest = await readJson(join(packageRoot, 'package.json'));
|
|
76
|
+
const apiUrl = manifest?.engineeringMemory?.apiUrl;
|
|
77
|
+
if (typeof apiUrl !== 'string' || apiUrl.length === 0) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
'This package was built without a backend address. Pass --api-url.',
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return apiUrl;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function main(argumentList) {
|
|
86
|
+
const [command, ...rest] = argumentList;
|
|
87
|
+
if (!command || command === '--help' || command === 'help') {
|
|
88
|
+
process.stdout.write(`${usage}\n`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (command !== 'install') {
|
|
92
|
+
throw new Error(`Unknown command: ${command}`);
|
|
93
|
+
}
|
|
94
|
+
const skillSource = join(packageRoot, 'skill');
|
|
95
|
+
if (!(await exists(join(skillSource, 'SKILL.md')))) {
|
|
96
|
+
throw new Error(`Skill source is missing in ${skillSource}`);
|
|
97
|
+
}
|
|
98
|
+
const staged = await stageBridgeRuntime();
|
|
99
|
+
try {
|
|
100
|
+
await runInstaller([
|
|
101
|
+
...rest,
|
|
102
|
+
...(rest.includes('--api-url')
|
|
103
|
+
? []
|
|
104
|
+
: ['--api-url', await publishedApiUrl()]),
|
|
105
|
+
'--skill-source',
|
|
106
|
+
skillSource,
|
|
107
|
+
'--bridge-runtime-source',
|
|
108
|
+
staged,
|
|
109
|
+
]);
|
|
110
|
+
} finally {
|
|
111
|
+
await rm(staged, { recursive: true, force: true });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
116
|
+
process.stderr.write(
|
|
117
|
+
`${error instanceof Error ? error.message : String(error)}\n`,
|
|
118
|
+
);
|
|
119
|
+
process.exitCode = 1;
|
|
120
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const START = '## Engineering Memory Dispatcher';
|
|
2
|
+
const END = 'Engineering Memory managed section end.';
|
|
3
|
+
|
|
4
|
+
export function upsertManagedSection(
|
|
5
|
+
existing,
|
|
6
|
+
body,
|
|
7
|
+
lineEnding = detectLineEnding(existing),
|
|
8
|
+
) {
|
|
9
|
+
const section = [START, body.trim(), END].join(lineEnding);
|
|
10
|
+
const startIndexes = findLineIndexes(existing, START);
|
|
11
|
+
const endIndexes = findLineIndexes(existing, END);
|
|
12
|
+
if (startIndexes.length !== endIndexes.length || startIndexes.length > 1) {
|
|
13
|
+
throw new Error('Engineering Memory dispatcher markers are malformed');
|
|
14
|
+
}
|
|
15
|
+
if (startIndexes.length === 0) {
|
|
16
|
+
if (existing.length === 0) return `${section}${lineEnding}`;
|
|
17
|
+
const separator = existing.endsWith('\n')
|
|
18
|
+
? lineEnding
|
|
19
|
+
: `${lineEnding}${lineEnding}`;
|
|
20
|
+
return `${existing}${separator}${section}${lineEnding}`;
|
|
21
|
+
}
|
|
22
|
+
const start = startIndexes[0];
|
|
23
|
+
const endLineStart = endIndexes[0];
|
|
24
|
+
if (endLineStart < start) {
|
|
25
|
+
throw new Error('Engineering Memory dispatcher markers are malformed');
|
|
26
|
+
}
|
|
27
|
+
const end = lineEnd(existing, endLineStart);
|
|
28
|
+
const suffix = existing.slice(end);
|
|
29
|
+
return `${existing.slice(0, start)}${section}${lineEnding}${suffix}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function hasManagedSection(value) {
|
|
33
|
+
return (
|
|
34
|
+
findLineIndexes(value, START).length === 1 &&
|
|
35
|
+
findLineIndexes(value, END).length === 1
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function detectLineEnding(value) {
|
|
40
|
+
return value.includes('\r\n') ? '\r\n' : '\n';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function findLineIndexes(value, target) {
|
|
44
|
+
const indexes = [];
|
|
45
|
+
let offset = 0;
|
|
46
|
+
for (const line of value.split(/(?<=\n)/)) {
|
|
47
|
+
const normalized = line.replace(/\r?\n$/, '');
|
|
48
|
+
if (normalized === target) indexes.push(offset);
|
|
49
|
+
offset += line.length;
|
|
50
|
+
}
|
|
51
|
+
return indexes;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function lineEnd(value, start) {
|
|
55
|
+
const newline = value.indexOf('\n', start);
|
|
56
|
+
return newline === -1 ? value.length : newline + 1;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const managedSectionMarkers = Object.freeze({ start: START, end: END });
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const shared = `This section is managed by the Engineering Memory installer.
|
|
2
|
+
|
|
3
|
+
For every task, search from the working directory toward the repository root for \`.engineering-memory/project.json\` before planning or editing.
|
|
4
|
+
|
|
5
|
+
When the marker exists, load the personal \`engineering-memory\` skill and use the \`engineering-memory\` MCP server. Call \`session.bootstrap\` before planning or editing. After compaction, a new chat, interruption, or handoff, call \`session.resume\` before continuing.
|
|
6
|
+
|
|
7
|
+
Do not edit until the skill lifecycle has completed discovery, its checkpoint, and \`context.prepare_change\`. Do not claim completion until \`task.verify\` succeeds.
|
|
8
|
+
|
|
9
|
+
When the repository is unbound, use the native questionnaire required by the skill. Do not silently create or bind a project. Do not open a survey web page.`;
|
|
10
|
+
|
|
11
|
+
export const dispatcherSections = Object.freeze({
|
|
12
|
+
codex: `${shared}\n\nInvoke the installed skill as \`$engineering-memory\` when an explicit skill invocation is needed.`,
|
|
13
|
+
claude: `${shared}\n\nInvoke the installed skill as \`/engineering-memory\` when an explicit skill invocation is needed.`,
|
|
14
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export function normalizeApiUrl(value, options = {}) {
|
|
2
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
3
|
+
throw new Error('--api-url is required');
|
|
4
|
+
}
|
|
5
|
+
let url;
|
|
6
|
+
try {
|
|
7
|
+
url = new URL(value.trim());
|
|
8
|
+
} catch {
|
|
9
|
+
throw new Error('--api-url must be an absolute URL');
|
|
10
|
+
}
|
|
11
|
+
if (url.username || url.password) {
|
|
12
|
+
throw new Error('--api-url cannot contain credentials');
|
|
13
|
+
}
|
|
14
|
+
if (url.search || url.hash) {
|
|
15
|
+
throw new Error('--api-url cannot contain a query string or fragment');
|
|
16
|
+
}
|
|
17
|
+
const isLocal = isLocalHost(url.hostname);
|
|
18
|
+
if (isLocal && options.development !== true) {
|
|
19
|
+
throw new Error('A localhost API URL requires --development');
|
|
20
|
+
}
|
|
21
|
+
if (!isLocal && url.protocol !== 'https:') {
|
|
22
|
+
throw new Error('Production API URL must use HTTPS');
|
|
23
|
+
}
|
|
24
|
+
if (isLocal && url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
25
|
+
throw new Error('Development localhost API URL must use HTTP or HTTPS');
|
|
26
|
+
}
|
|
27
|
+
return url.href.replace(/\/$/, '');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isLocalHost(value) {
|
|
31
|
+
const hostname = value.toLowerCase().replace(/\.$/, '');
|
|
32
|
+
return (
|
|
33
|
+
hostname === 'localhost' ||
|
|
34
|
+
hostname.endsWith('.localhost') ||
|
|
35
|
+
hostname.startsWith('127.') ||
|
|
36
|
+
hostname === '[::1]' ||
|
|
37
|
+
hostname === '::1'
|
|
38
|
+
);
|
|
39
|
+
}
|
package/install/cli.mjs
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import { installEngineeringMemory } from './installer.mjs';
|
|
4
|
+
|
|
5
|
+
const usage = `Usage:
|
|
6
|
+
node client/install/cli.mjs [options]
|
|
7
|
+
|
|
8
|
+
Options:
|
|
9
|
+
--clients codex,claude
|
|
10
|
+
--api-url <https-url>
|
|
11
|
+
--development
|
|
12
|
+
--repo <repository-root>
|
|
13
|
+
--hook-mode chain|verify-only|cancel
|
|
14
|
+
--skill-source <directory>
|
|
15
|
+
--bridge-runtime-source <directory>
|
|
16
|
+
--bridge-entry <file>
|
|
17
|
+
--gate-entry <file>
|
|
18
|
+
--help
|
|
19
|
+
|
|
20
|
+
Choose hook mode through the Codex or Claude native questionnaire before running this command. The installer never opens a questionnaire or authentication web page.`;
|
|
21
|
+
|
|
22
|
+
export function parseInstallerArguments(arguments_) {
|
|
23
|
+
const result = {};
|
|
24
|
+
for (let index = 0; index < arguments_.length; index += 1) {
|
|
25
|
+
const argument = arguments_[index];
|
|
26
|
+
if (argument === '--help') return { help: true };
|
|
27
|
+
if (argument === '--development') {
|
|
28
|
+
result.development = true;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const value = arguments_[index + 1];
|
|
32
|
+
if (!value || value.startsWith('--')) {
|
|
33
|
+
throw new Error(`${argument} requires a value`);
|
|
34
|
+
}
|
|
35
|
+
if (argument === '--clients') {
|
|
36
|
+
result.selectedClients = value
|
|
37
|
+
.split(',')
|
|
38
|
+
.map((entry) => entry.trim())
|
|
39
|
+
.filter(Boolean);
|
|
40
|
+
} else if (argument === '--api-url') {
|
|
41
|
+
result.apiUrl = value;
|
|
42
|
+
} else if (argument === '--repo') {
|
|
43
|
+
result.repoRoot = resolve(value);
|
|
44
|
+
} else if (argument === '--hook-mode') {
|
|
45
|
+
result.hookMode = value;
|
|
46
|
+
} else if (argument === '--skill-source') {
|
|
47
|
+
result.skillSource = resolve(value);
|
|
48
|
+
} else if (argument === '--bridge-runtime-source') {
|
|
49
|
+
result.bridgeRuntimeSource = resolve(value);
|
|
50
|
+
} else if (argument === '--bridge-entry') {
|
|
51
|
+
result.bridgeEntry = resolve(value);
|
|
52
|
+
} else if (argument === '--gate-entry') {
|
|
53
|
+
result.gateEntry = resolve(value);
|
|
54
|
+
} else {
|
|
55
|
+
throw new Error(`Unknown installer argument: ${argument}`);
|
|
56
|
+
}
|
|
57
|
+
index += 1;
|
|
58
|
+
}
|
|
59
|
+
if (!result.apiUrl) {
|
|
60
|
+
throw new Error('--api-url is required');
|
|
61
|
+
}
|
|
62
|
+
if (result.repoRoot && !result.hookMode) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
'--hook-mode is required with --repo after the native hook questionnaire is answered',
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (!result.repoRoot && result.hookMode && result.hookMode !== 'cancel') {
|
|
68
|
+
throw new Error('--repo is required when installing a Git hook');
|
|
69
|
+
}
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function runInstaller(arguments_ = process.argv.slice(2)) {
|
|
74
|
+
const options = parseInstallerArguments(arguments_);
|
|
75
|
+
if (options.help) {
|
|
76
|
+
process.stdout.write(`${usage}\n`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const result = await installEngineeringMemory(options);
|
|
80
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (
|
|
84
|
+
process.argv[1] &&
|
|
85
|
+
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
|
86
|
+
) {
|
|
87
|
+
void runInstaller().catch((error) => {
|
|
88
|
+
process.stderr.write(
|
|
89
|
+
`${error instanceof Error ? error.message : String(error)}\n`,
|
|
90
|
+
);
|
|
91
|
+
process.exitCode = 1;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { access } from 'node:fs/promises';
|
|
4
|
+
import { delimiter, join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
const metaCharacters = /([()\][%!^"`<>&|;, *?])/g;
|
|
7
|
+
const defaultPathExtensions = '.COM;.EXE;.BAT;.CMD';
|
|
8
|
+
const shimExtension = /\.(?:cmd|bat)$/i;
|
|
9
|
+
const pathSeparators = /[/\\]/;
|
|
10
|
+
|
|
11
|
+
export async function runCommand(command, args, options = {}) {
|
|
12
|
+
const environment = options.env ?? process.env;
|
|
13
|
+
const invocation = await planInvocation(command, args, environment, options);
|
|
14
|
+
return await new Promise((resolve) => {
|
|
15
|
+
const child = spawn(invocation.command, invocation.args, {
|
|
16
|
+
cwd: options.cwd,
|
|
17
|
+
env: environment,
|
|
18
|
+
shell: false,
|
|
19
|
+
windowsHide: true,
|
|
20
|
+
windowsVerbatimArguments: invocation.verbatim,
|
|
21
|
+
});
|
|
22
|
+
let stdout = '';
|
|
23
|
+
let stderr = '';
|
|
24
|
+
child.stdout?.setEncoding('utf8');
|
|
25
|
+
child.stderr?.setEncoding('utf8');
|
|
26
|
+
child.stdout?.on('data', (value) => {
|
|
27
|
+
stdout += value;
|
|
28
|
+
});
|
|
29
|
+
child.stderr?.on('data', (value) => {
|
|
30
|
+
stderr += value;
|
|
31
|
+
});
|
|
32
|
+
child.on('error', (error) => {
|
|
33
|
+
resolve({ code: null, stdout, stderr, error });
|
|
34
|
+
});
|
|
35
|
+
child.on('close', (code) => {
|
|
36
|
+
resolve({ code, stdout, stderr, error: null });
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function planInvocation(
|
|
42
|
+
command,
|
|
43
|
+
args,
|
|
44
|
+
environment = process.env,
|
|
45
|
+
options = {},
|
|
46
|
+
) {
|
|
47
|
+
const platform = options.platform ?? process.platform;
|
|
48
|
+
if (platform !== 'win32') {
|
|
49
|
+
return { command, args, verbatim: false };
|
|
50
|
+
}
|
|
51
|
+
const resolveExecutable =
|
|
52
|
+
options.resolveExecutable ?? resolveWindowsExecutable;
|
|
53
|
+
const executable = await resolveExecutable(command, environment);
|
|
54
|
+
if (!executable) {
|
|
55
|
+
return { command, args, verbatim: false };
|
|
56
|
+
}
|
|
57
|
+
if (!shimExtension.test(executable)) {
|
|
58
|
+
return { command: executable, args, verbatim: false };
|
|
59
|
+
}
|
|
60
|
+
const comSpec = environment.ComSpec ?? environment.COMSPEC ?? 'cmd.exe';
|
|
61
|
+
const line = [escapeCommand(executable), ...args.map(escapeArgument)].join(
|
|
62
|
+
' ',
|
|
63
|
+
);
|
|
64
|
+
return {
|
|
65
|
+
command: comSpec,
|
|
66
|
+
args: ['/d', '/s', '/c', `"${line}"`],
|
|
67
|
+
verbatim: true,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function resolveWindowsExecutable(command, environment) {
|
|
72
|
+
const extensions = (environment.PATHEXT ?? defaultPathExtensions)
|
|
73
|
+
.split(';')
|
|
74
|
+
.map((value) => value.trim())
|
|
75
|
+
.filter(Boolean);
|
|
76
|
+
const named = command.toLowerCase();
|
|
77
|
+
const carriesExtension = extensions.some((extension) =>
|
|
78
|
+
named.endsWith(extension.toLowerCase()),
|
|
79
|
+
);
|
|
80
|
+
if (pathSeparators.test(command)) {
|
|
81
|
+
return await firstExistingFile(command, extensions, carriesExtension);
|
|
82
|
+
}
|
|
83
|
+
const searchPath = environment.Path ?? environment.PATH ?? '';
|
|
84
|
+
for (const directory of searchPath.split(delimiter).filter(Boolean)) {
|
|
85
|
+
const candidate = await firstExistingFile(
|
|
86
|
+
join(unquote(directory), command),
|
|
87
|
+
extensions,
|
|
88
|
+
carriesExtension,
|
|
89
|
+
);
|
|
90
|
+
if (candidate) return candidate;
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function firstExistingFile(base, extensions, carriesExtension) {
|
|
96
|
+
if (carriesExtension) {
|
|
97
|
+
return (await isReadableFile(base)) ? base : null;
|
|
98
|
+
}
|
|
99
|
+
for (const extension of extensions) {
|
|
100
|
+
const candidate = `${base}${extension}`;
|
|
101
|
+
if (await isReadableFile(candidate)) return candidate;
|
|
102
|
+
}
|
|
103
|
+
return (await isReadableFile(base)) ? base : null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function isReadableFile(candidate) {
|
|
107
|
+
try {
|
|
108
|
+
await access(candidate, constants.R_OK);
|
|
109
|
+
return true;
|
|
110
|
+
} catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function unquote(value) {
|
|
116
|
+
return value.startsWith('"') && value.endsWith('"')
|
|
117
|
+
? value.slice(1, -1)
|
|
118
|
+
: value;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function escapeCommand(value) {
|
|
122
|
+
return String(value).replace(metaCharacters, '^$1');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function escapeArgument(value) {
|
|
126
|
+
const escaped = String(value)
|
|
127
|
+
.replace(/(\\*)"/g, '$1$1\\"')
|
|
128
|
+
.replace(/(\\*)$/, '$1$1');
|
|
129
|
+
return `"${escaped}"`
|
|
130
|
+
.replace(metaCharacters, '^$1')
|
|
131
|
+
.replace(metaCharacters, '^$1');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function commandFailure(command, result) {
|
|
135
|
+
const detail =
|
|
136
|
+
result.error?.message || result.stderr.trim() || result.stdout.trim();
|
|
137
|
+
return detail
|
|
138
|
+
? `${command}: ${detail}`
|
|
139
|
+
: `${command} exited with code ${result.code}`;
|
|
140
|
+
}
|