Skip to content

Commit bca9d0a

Browse files
committed
Release 0.9.2 — npm + Docker distribution
First public release of @st-gr/sail-proxy. Highlights: - CLI installable via `npm install -g @st-gr/sail-proxy`, with interactive setup that parses an SAP BTP AI Core service key into ~/.sail-proxy/.env. Defaults to deployment auto-discovery so `sail-proxy run` is functional after first-run. - Gateway and ollama services bundled into the npm tarball with their production dependencies and @libs alias resolution. CLI's run/ ollama-start spawn from the bundled layout. - Provider APIs: OpenAI (chat + embeddings), Anthropic, OpenRouter, AWS Bedrock (invoke/converse, streaming variants), Ollama API compatibility on port 11434. - API key + AWS credential management with persistent storage. - Logs subsystem: tail, follow, since, clear. - Docker images for gateway, admin, ollama, nginx. Publish pipeline hardened against npm publish lifecycle pitfalls (workspace: protocol rewriting, registry-manifest re-read after pack, bundled-dep packaging, regression guards in publish-npm.js).
1 parent bcd087a commit bca9d0a

22 files changed

Lines changed: 1133 additions & 129 deletions

ci/ci-pipeline.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ async function parseDockerComposeConfig() {
9191
config.volumes.valkey = valkeyMatch[1];
9292
}
9393

94-
// Parse image names (format: "image: ${DOCKER_REGISTRY:-ghcr.io}/${DOCKER_ORGANIZATION:-st-gr}/sail-proxy-NAME:${DOCKER_TAG:-1.0.0}")
94+
// Parse image names (format: "image: ${DOCKER_REGISTRY:-ghcr.io}/${DOCKER_ORGANIZATION:-st-gr}/sail-proxy-NAME:${DOCKER_TAG:-latest}")
9595
const imageRegex = /image:\s*\$\{DOCKER_REGISTRY:-([^}]+)\}\/\$\{DOCKER_ORGANIZATION:-([^}]+)\}\/(sail-proxy-\w+):\$\{DOCKER_TAG:-[^}]+\}/g;
9696
let match;
9797
while ((match = imageRegex.exec(content)) !== null) {

cli-tools/bundle-gateway-libs.js

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Populate npm-dist/sail-proxy/bundled/gateway/node_modules/@libs/<name>/ from
5+
* the already-bundled bundled/gateway/libs/<name>/ tree.
6+
*
7+
* The gateway's TypeScript sources use `import ... from '@libs/<name>'` and the
8+
* tsconfig defines `paths: { "@libs/*": ["libs/*"] }`. tsc does not rewrite path
9+
* aliases on emit, so the compiled JS still contains literal `require("@libs/...")`.
10+
* In production the only way to resolve those without a runtime path-mapper is
11+
* Node's standard module resolution — which means each lib must appear under
12+
* `node_modules/@libs/<name>/`.
13+
*
14+
* The Docker image accomplishes this with a symlink (`ln -sf ./libs node_modules/@libs`).
15+
* Internal symlinks in npm tarballs are unreliable cross-platform (Windows in
16+
* particular), so this script does a real copy. Libs are tens of KB total.
17+
*
18+
* Must run AFTER `npm install --production` in bundled/gateway/, otherwise
19+
* npm's pruning pass would strip our @libs entries as "extraneous".
20+
*/
21+
22+
const fs = require('fs');
23+
const path = require('path');
24+
25+
const projectRoot = path.resolve(__dirname, '..');
26+
const gatewayBundleDir = path.join(projectRoot, 'npm-dist', 'sail-proxy', 'bundled', 'gateway');
27+
const libsSrc = path.join(gatewayBundleDir, 'libs');
28+
const libsDst = path.join(gatewayBundleDir, 'node_modules', '@libs');
29+
30+
function main() {
31+
if (!fs.existsSync(libsSrc)) {
32+
throw new Error(
33+
`bundled/gateway/libs not found at ${libsSrc} — bundle:gateway must run before bundle:gateway-aliases.`
34+
);
35+
}
36+
37+
fs.rmSync(libsDst, { force: true, recursive: true });
38+
fs.mkdirSync(libsDst, { recursive: true });
39+
40+
const entries = fs.readdirSync(libsSrc, { withFileTypes: true });
41+
const copied = [];
42+
for (const entry of entries) {
43+
if (!entry.isDirectory()) continue;
44+
const src = path.join(libsSrc, entry.name);
45+
const dst = path.join(libsDst, entry.name);
46+
fs.cpSync(src, dst, { recursive: true });
47+
copied.push(entry.name);
48+
}
49+
50+
if (copied.length === 0) {
51+
throw new Error(`No lib subdirectories found under ${libsSrc}`);
52+
}
53+
54+
console.log(
55+
`✅ Mirrored ${copied.length} libs into ${path.relative(projectRoot, libsDst)}: ${copied.join(', ')}`
56+
);
57+
}
58+
59+
if (require.main === module) {
60+
try {
61+
main();
62+
} catch (err) {
63+
console.error('❌ bundle-gateway-libs failed:', err.message);
64+
process.exit(1);
65+
}
66+
}
67+
68+
module.exports = { main };
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Bundle libs/service-key-parser into npm-dist/sail-proxy as a real node_modules
5+
* entry (not a symlink) so `npm pack` includes it via bundledDependencies.
6+
*
7+
* In a pnpm workspace, npm-dist/sail-proxy/node_modules/@sap-llm-gateway/service-key-parser
8+
* is a symlink to ../../../../libs/service-key-parser. npm pack does not follow that
9+
* external symlink even with bundledDependencies set, so the published tarball has
10+
* no copy of the lib and `npm install @st-gr/sail-proxy` fails with E404 on the dep.
11+
*
12+
* This script replaces the symlink with a real directory containing only the built
13+
* artifacts and a stripped package.json. pnpm install will re-link on next run.
14+
*/
15+
16+
const { execSync } = require('child_process');
17+
const fs = require('fs');
18+
const path = require('path');
19+
20+
const projectRoot = path.resolve(__dirname, '..');
21+
const libDir = path.join(projectRoot, 'libs', 'service-key-parser');
22+
const targetDir = path.join(
23+
projectRoot,
24+
'npm-dist',
25+
'sail-proxy',
26+
'node_modules',
27+
'@sap-llm-gateway',
28+
'service-key-parser'
29+
);
30+
31+
function main() {
32+
if (!fs.existsSync(libDir)) {
33+
throw new Error(`libs/service-key-parser not found at ${libDir}`);
34+
}
35+
36+
execSync('pnpm build', { cwd: libDir, stdio: 'inherit' });
37+
38+
const distSrc = path.join(libDir, 'dist');
39+
if (!fs.existsSync(distSrc)) {
40+
throw new Error(`Build output missing: ${distSrc}`);
41+
}
42+
43+
// Replace symlink (or stale dir) with a fresh real directory.
44+
fs.rmSync(targetDir, { force: true, recursive: true });
45+
fs.mkdirSync(targetDir, { recursive: true });
46+
47+
fs.cpSync(distSrc, path.join(targetDir, 'dist'), { recursive: true });
48+
49+
// Ship a minimal package.json — keep main/types/version, drop scripts/devDeps
50+
// so npm install on the consumer side has nothing to run.
51+
const libPkg = JSON.parse(fs.readFileSync(path.join(libDir, 'package.json'), 'utf8'));
52+
const shipped = {
53+
name: libPkg.name,
54+
version: libPkg.version,
55+
main: libPkg.main,
56+
types: libPkg.types,
57+
dependencies: libPkg.dependencies || {}
58+
};
59+
fs.writeFileSync(
60+
path.join(targetDir, 'package.json'),
61+
JSON.stringify(shipped, null, 2) + '\n',
62+
'utf8'
63+
);
64+
65+
console.log(
66+
`✅ Bundled @sap-llm-gateway/service-key-parser@${libPkg.version} into ${path.relative(projectRoot, targetDir)}`
67+
);
68+
}
69+
70+
if (require.main === module) {
71+
try {
72+
main();
73+
} catch (err) {
74+
console.error('❌ bundle-service-key-parser failed:', err.message);
75+
process.exit(1);
76+
}
77+
}
78+
79+
module.exports = { main };

cli-tools/publish-npm.js

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Publish npm-dist/sail-proxy to npmjs.com.
5+
*
6+
* The bug this script defends against: npm publish (npm/cli's lib/commands/publish.js)
7+
* re-reads package.json from disk AFTER pack() returns, and ships THAT manifest to
8+
* the registry. If a postpack hook restores workspace:* between the tarball being
9+
* built and the manifest being re-read, the tarball is correct but the registry
10+
* metadata gets workspace:*, and `npm install` fails with EUNSUPPORTEDPROTOCOL.
11+
*
12+
* Defenses:
13+
* 1. The npm-dist/sail-proxy package.json must NOT have a postpack hook that
14+
* restores workspace:* — verified below.
15+
* 2. We pre-rewrite workspace:* before invoking npm publish (prepack does this
16+
* too, so it's defense in depth).
17+
* 3. We verify the on-disk dependency state matches what we expect to ship,
18+
* ABORTING if any @sap-llm-gateway dep still has workspace:*.
19+
* 4. The wrapper restores workspace:* in a finally block so the working tree
20+
* is clean whether publish succeeded, failed, or the user interrupted.
21+
*/
22+
23+
const { execSync } = require('child_process');
24+
const fs = require('fs');
25+
const path = require('path');
26+
27+
const projectRoot = path.resolve(__dirname, '..');
28+
const pkgDir = path.join(projectRoot, 'npm-dist', 'sail-proxy');
29+
const pkgJsonPath = path.join(pkgDir, 'package.json');
30+
const prepareForPack = require('./prepare-for-pack');
31+
const restoreWorkspace = require('./restore-workspace-protocol');
32+
33+
function run(cmd) {
34+
execSync(cmd, { cwd: pkgDir, stdio: 'inherit' });
35+
}
36+
37+
function readPkg() {
38+
return JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
39+
}
40+
41+
function assertNoPostpackHook() {
42+
const pkg = readPkg();
43+
if (pkg.scripts && pkg.scripts.postpack) {
44+
throw new Error(
45+
`Refusing to publish: npm-dist/sail-proxy/package.json has a postpack hook ` +
46+
`(${pkg.scripts.postpack}). npm publish re-reads package.json from disk after ` +
47+
`pack() runs, so a postpack hook that restores workspace:* will poison the ` +
48+
`registry manifest while leaving the tarball correct. Remove the postpack ` +
49+
`hook — the wrapper's finally block handles workspace:* restoration after ` +
50+
`publish completes.`
51+
);
52+
}
53+
}
54+
55+
function assertNoWorkspaceProtocol(stage) {
56+
const pkg = readPkg();
57+
const offenders = [];
58+
for (const [name, version] of Object.entries(pkg.dependencies || {})) {
59+
if (typeof version === 'string' && version.startsWith('workspace:')) {
60+
offenders.push(`${name}: ${version}`);
61+
}
62+
}
63+
if (offenders.length > 0) {
64+
throw new Error(
65+
`Refusing to publish: ${stage}, package.json still has workspace: protocol in ` +
66+
`dependencies:\n ${offenders.join('\n ')}\n` +
67+
`This is the exact state npm publish would re-read for the registry manifest. ` +
68+
`Run prepare-for-pack.js or check that the prepack hook is wired up.`
69+
);
70+
}
71+
}
72+
73+
let exitCode = 0;
74+
let cleanupNeeded = false;
75+
76+
try {
77+
run('npm run check-version');
78+
79+
// Defense 1: bail early if the postpack-restore footgun is back.
80+
assertNoPostpackHook();
81+
82+
// Defense 2: rewrite workspace:* before npm publish reads the file.
83+
prepareForPack.main();
84+
cleanupNeeded = true;
85+
86+
// Defense 3: verify on-disk state is publishable.
87+
// This is the same disk state npm's publish.js will re-read at line 112
88+
// (after pack/postpack runs) for the registry manifest.
89+
assertNoWorkspaceProtocol('after prepare-for-pack');
90+
91+
const extraArgs = process.argv.slice(2).join(' ');
92+
run(`npm publish${extraArgs ? ' ' + extraArgs : ''}`);
93+
} catch (err) {
94+
if (err && err.message && err.message.startsWith('Refusing to publish')) {
95+
console.error('\n❌ ' + err.message);
96+
exitCode = 1;
97+
} else {
98+
exitCode = typeof err.status === 'number' ? err.status : 1;
99+
}
100+
} finally {
101+
if (cleanupNeeded) {
102+
try {
103+
restoreWorkspace.main();
104+
} catch (cleanupErr) {
105+
console.error('⚠️ Cleanup (restore-workspace-protocol) failed:', cleanupErr.message);
106+
if (exitCode === 0) exitCode = 1;
107+
}
108+
}
109+
}
110+
111+
process.exit(exitCode);

0 commit comments

Comments
 (0)