amsd-pipeline 1.9.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.
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // THIS FILE IS DELIBERATELY THIN. Every real decision — self-clone, packaging, docker isolation,
5
+ // uninstall — lives in install.sh, sourced from orchestrations-installer/install.sh at publish
6
+ // time (see ../sync-install-sh.sh) and never duplicated here. `npx amsd-pipeline` exists only to
7
+ // get a user from "no git commands" to "the same install.sh a colleague running from a checkout
8
+ // would run" with zero divergence between the two paths.
9
+ const { spawnSync } = require('node:child_process');
10
+ const path = require('node:path');
11
+
12
+ const installScript = path.join(__dirname, '..', 'install.sh');
13
+ const result = spawnSync('bash', [installScript, ...process.argv.slice(2)], { stdio: 'inherit' });
14
+
15
+ if (result.error) {
16
+ // bash itself could not be found/spawned — a clear message beats a raw ENOENT stack.
17
+ process.stderr.write(`amsd-pipeline: could not run bash (${result.error.message})\n`);
18
+ process.exit(1);
19
+ }
20
+ process.exit(result.status === null ? 1 : result.status);
package/install.sh ADDED
@@ -0,0 +1,723 @@
1
+ #!/usr/bin/env bash
2
+ # install.sh — set this machine up to run the pipeline.
3
+ #
4
+ # ./orchestrations-installer/install.sh install for the default stack, with dashboards if docker is up
5
+ # ./install.sh --stack codemie install for a specific stack
6
+ # ./install.sh --no-docker skip dashboards entirely
7
+ # ./install.sh --check verify an existing install, change nothing
8
+ # ./install.sh --dest ~/somewhere --ref v1.7 package that ref into a NEW tree, then install it
9
+ #
10
+ # --dest IS THE WHOLE POINT FOR ANYONE WHO IS NOT THIS CHECKOUT. Without it, install.sh only ever
11
+ # configures the tree it is already sitting inside — which presupposes the code already arrived by
12
+ # some means nobody ever automated. A colleague with repo access runs ONE command, from their own
13
+ # clone, naming a tagged commit on the shared remote: no manual git archive, no manual re-provision,
14
+ # no LLM standing in for either.
15
+ #
16
+ # DOCKER IS OPTIONAL, ALWAYS. The dashboards are observability; the pipeline runs without them.
17
+ # An installer that fails because a container is missing teaches people to skip the installer.
18
+ #
19
+ # The stacks, their runners and the services are all DECLARED (orchestrations/config/*). This
20
+ # script names none of them — adding a stack is a config edit, not an edit here.
21
+ set -uo pipefail
22
+
23
+ # THE INSTALLER LIVES IN ITS OWN FOLDER; ROOT IS THE TREE IT INSTALLS.
24
+ #
25
+ # All 17 uses of $ROOT below address the install TREE — .env, dist/, orchestrations/, the compose
26
+ # file, the manifest. Resolving ROOT from this script's own directory would point every one of them
27
+ # inside orchestrations-installer/ and the install would silently configure the wrong tree.
28
+ #
29
+ # INSTALLER_DIR is separate and is only for this folder's own files, so the two can never be
30
+ # confused by a later edit.
31
+ INSTALLER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
32
+ ROOT="$(cd "$INSTALLER_DIR/.." && pwd)"
33
+ CONFIG="$ROOT/orchestrations/config"
34
+ NODE_BIN="${NODE_BIN:-node}"
35
+
36
+ _ok() { printf '\033[0;32m ✓\033[0m %s\n' "$*"; }
37
+ _warn() { printf '\033[0;33m !\033[0m %s\n' "$*"; }
38
+ _bad() { printf '\033[0;31m ✗\033[0m %s\n' "$*" >&2; }
39
+ _head() { printf '\n\033[1m%s\033[0m\n' "$*"; }
40
+
41
+ STACK=""; USE_DOCKER=auto; CHECK_ONLY=0; FAILED=0
42
+
43
+ # THE CONTAINER RUNTIME IS DECLARED, NEVER INFERRED (plan §5.1a).
44
+ #
45
+ # Podman is the default on Windows because Docker Desktop needs a paid subscription above 250
46
+ # employees or $10M revenue. A procurement conversation, not a technical preference, is what stalls
47
+ # a rollout — and Podman on Windows runs on WSL2 too, so this is not a fourth platform.
48
+ CONTAINER_RUNTIME="${EPAM_CONTAINER_RUNTIME:-}"
49
+
50
+ # REPLAY IS A CONFIG OPTION (plan §5.1c). Langfuse is the RECORDER, not a dashboard: a run executed
51
+ # without it can never be replayed, because the turns were never captured. Off by default — 2.36GB
52
+ # of images is not a silent opt-in — but the consequence is one-way, so it is STATED either way.
53
+ REPLAY_MODE="${EPAM_REPLAY:-off}"
54
+ DEST=""; REF=""; REPO_URL=""; UNINSTALL=0
55
+ while [ $# -gt 0 ]; do
56
+ case "$1" in
57
+ --dest) DEST="${2:-}"; shift 2 ;;
58
+ --ref) REF="${2:-}"; shift 2 ;;
59
+ --repo) REPO_URL="${2:-}"; shift 2 ;;
60
+ --stack) STACK="${2:-}"; shift 2 ;;
61
+ --no-docker) USE_DOCKER=no; shift ;;
62
+ --docker) USE_DOCKER=yes; shift ;;
63
+ --check) CHECK_ONLY=1; shift ;;
64
+ --uninstall) UNINSTALL=1; shift ;;
65
+ --help|-h) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
66
+ *) _bad "unknown option '$1'"; exit 1 ;;
67
+ esac
68
+ done
69
+
70
+ # ── Uninstall: the docker footprint ONLY, never the files on disk ────────────
71
+ #
72
+ # "we then have to be able to safely uninstall without affecting dev environment" (operator,
73
+ # 2026-09-03). STRUCTURALLY incapable of touching the dev environment, not merely unlikely to:
74
+ # isolated_project_name() always produces "test-install-amsd-pipeline-<suffix>-<number>"; the real
75
+ # hand-run dev stack names itself "dev-amsd-pipeline"/"dev-amsd-pipeline-launch" via each compose
76
+ # file's own top-level `name:` key. Those two prefixes cannot collide — verified live against real
77
+ # Docker, not assumed: the dev stack's actual volumes/networks/project name were confirmed
78
+ # completely distinct from an isolated install's.
79
+ #
80
+ # NEVER THE FILES. Run evidence and .env live under $ROOT (or --dest) either way — uninstall
81
+ # removes containers, the network, the volumes AND any images built for THIS install's own project
82
+ # (never a shared base image — `--rmi local` only removes images built by this compose file, not
83
+ # pulled ones), then prunes now-dangling build layers left behind by `--build`, and reports that
84
+ # the directory itself was left alone, in case an operator was about to `rm -rf` it on the
85
+ # assumption uninstall already did.
86
+ if [ "$UNINSTALL" = "1" ]; then
87
+ _head "Uninstall"
88
+ _UN_ROOT="$ROOT"
89
+ if [ -n "$DEST" ]; then
90
+ _UN_ROOT="$(cd "$DEST" 2>/dev/null && pwd)"
91
+ if [ -z "$_UN_ROOT" ]; then
92
+ _bad "--dest $DEST does not exist — nothing to uninstall there"
93
+ exit 1
94
+ fi
95
+ fi
96
+ . "$INSTALLER_DIR/lib/isolated-compose-identity.sh"
97
+ if [ -f "$INSTALLER_DIR/lib/container-runtime.sh" ]; then
98
+ . "$INSTALLER_DIR/lib/container-runtime.sh"
99
+ else
100
+ _bad "missing $INSTALLER_DIR/lib/container-runtime.sh — cannot resolve a container runtime"
101
+ exit 1
102
+ fi
103
+ if ! CONTAINER_RUNTIME="$(container_runtime 2>/dev/null)"; then
104
+ _ok "no container runtime found — nothing docker-related to uninstall"
105
+ exit 0
106
+ fi
107
+ _UN_OBS_PROJECT="$(isolated_project_name "$_UN_ROOT" obs)"
108
+ _UN_LD_PROJECT="$(isolated_project_name "$_UN_ROOT" launch)"
109
+ for _UN_SPEC in "docker-compose.observability.yml:$_UN_OBS_PROJECT" "launch-dashboard/docker-compose.yml:$_UN_LD_PROJECT"; do
110
+ _UN_FILE="${_UN_SPEC%%:*}"; _UN_PROJECT="${_UN_SPEC##*:}"
111
+ _UN_COMPOSE="$_UN_ROOT/$_UN_FILE"
112
+ if [ -f "$_UN_COMPOSE" ]; then
113
+ if (cd "$(dirname "$_UN_COMPOSE")" && container_compose \
114
+ -f "$(basename "$_UN_COMPOSE")" -p "$_UN_PROJECT" down -v --remove-orphans --rmi local) >/dev/null 2>&1; then
115
+ _ok "removed $_UN_PROJECT (containers, network, volumes, images)"
116
+ else
117
+ _ok "$_UN_PROJECT: nothing to remove or already gone"
118
+ fi
119
+ # SCOPED PRUNE, never a bare `docker system prune` — that would also sweep up the dev
120
+ # environment's own dangling layers. Compose stamps every image it builds with this
121
+ # label, so filtering on it prunes only leftovers this exact project could have made.
122
+ "$CONTAINER_RUNTIME" image prune -f --filter "label=com.docker.compose.project=$_UN_PROJECT" >/dev/null 2>&1 || true
123
+ fi
124
+ done
125
+ _ok "files under $_UN_ROOT were NOT touched — remove the directory yourself when you are done with it"
126
+ exit 0
127
+ fi
128
+
129
+ # ── Package a ref into a NEW tree, then install THAT — never a hand-run git archive ──
130
+ #
131
+ # THE SOURCE OF TRUTH IS THE GIT COMMIT, never a manual copy. `git archive` reads only tracked,
132
+ # committed content: untracked and gitignored files (real credentials among them) are structurally
133
+ # excluded, not filtered by a list that can miss one — proven this same repo: a raw tar shipped 8
134
+ # live credentials that git archive does not even see.
135
+ #
136
+ # INSTALLER_DIR NORMALLY NEVER MOVES. Every lib/*.sh this script sources still comes from where
137
+ # THIS install.sh lives, regardless of --dest — the archived copy that lands inside DEST is inert
138
+ # bystander content, same as it always has been. What --dest changes is $ROOT: everything below
139
+ # (.env, dist/, the compose files, the manifest) now addresses the freshly-packaged tree instead of
140
+ # wherever this script happened to be sitting.
141
+ #
142
+ # THE ONE EXCEPTION: a bare install.sh with no lib/ directory next to it (obtained alone — npx, a
143
+ # raw single-file download) self-clones below, and INSTALLER_DIR is repointed at that fresh clone's
144
+ # own orchestrations-installer/ — the only tree that is guaranteed to actually have the lib files
145
+ # this script is about to source.
146
+ if [ -n "$DEST" ]; then
147
+ _head "Packaging"
148
+ if git -C "$INSTALLER_DIR" rev-parse --git-dir >/dev/null 2>&1; then
149
+ # THE REPO ROOT, NOT WHEREVER install.sh HAPPENS TO LIVE. `git archive` scopes its output
150
+ # to the CURRENT WORKING TREE'S SUBDIRECTORY, not the whole repo — running it with `-C
151
+ # $INSTALLER_DIR` (orchestrations-installer/, a SUBDIRECTORY) archived only install.sh and
152
+ # lib/, silently dropping the entire rest of the pipeline. Caught by actually running the
153
+ # packaged result and watching the very next step fail with "no provider-sets.json", not by
154
+ # reading the git-archive docs and assuming.
155
+ _GIT_ROOT="$(git -C "$INSTALLER_DIR" rev-parse --show-toplevel 2>/dev/null)"
156
+ if [ -z "$_GIT_ROOT" ]; then
157
+ _bad "could not resolve the repo root from $INSTALLER_DIR"
158
+ exit 1
159
+ fi
160
+ else
161
+ # NOTHING PRE-EXISTING REQUIRED. The only thing anyone needs to have obtained is
162
+ # install.sh ITSELF — however (npx, a raw download, a shared drive) — everything else,
163
+ # including the clone, happens here. This is what makes "for other people, not just me,
164
+ # and no local to use" actually true: someone with only this one file and git+node on PATH
165
+ # gets a full working install, no separate `git clone` step for them to run by hand.
166
+ #
167
+ # PUBLIC HTTPS, so no credential is needed to obtain it — the repo is public. --repo (or
168
+ # EPAM_REPO) overrides for a fork or a private mirror.
169
+ _REPO_URL="${REPO_URL:-${EPAM_REPO:-https://github.com/dune94/epam-cli.git}}"
170
+ _CLONE_DIR="$(mktemp -d)" || { _bad "could not create a temp directory to clone into"; exit 1; }
171
+ if ! git clone --quiet "$_REPO_URL" "$_CLONE_DIR" 2>&1; then
172
+ _bad "could not clone $_REPO_URL — check network access, or pass --repo for a different URL"
173
+ exit 1
174
+ fi
175
+ _ok "cloned $_REPO_URL"
176
+ _GIT_ROOT="$_CLONE_DIR"
177
+ # INSTALLER_DIR NOW MOVES — the one exception to the rule stated below. A bare install.sh
178
+ # obtained alone (npx, a raw single-file download) has no lib/ directory sitting next to
179
+ # it at all; every lib/*.sh source below would fail "No such file or directory" otherwise.
180
+ # The freshly-cloned tree has a complete, version-consistent copy of everything install.sh
181
+ # needs, so it becomes the new INSTALLER_DIR.
182
+ if [ -d "$_CLONE_DIR/orchestrations-installer/lib" ]; then
183
+ INSTALLER_DIR="$_CLONE_DIR/orchestrations-installer"
184
+ fi
185
+ fi
186
+ _PKG_REF="${REF:-HEAD}"
187
+ # FETCH FIRST: a colleague packaging a release just tagged by someone else may not have it yet.
188
+ git -C "$_GIT_ROOT" fetch --tags --quiet >/dev/null 2>&1 || true
189
+ if ! git -C "$_GIT_ROOT" rev-parse --verify "${_PKG_REF}^{commit}" >/dev/null 2>&1; then
190
+ _bad "ref '$_PKG_REF' does not exist in this checkout, even after fetching tags"
191
+ exit 1
192
+ fi
193
+ mkdir -p "$DEST" || { _bad "could not create $DEST"; exit 1; }
194
+ DEST="$(cd "$DEST" && pwd)"
195
+ # AN UPDATE MUST NEVER DESTROY RUN EVIDENCE. Extracting the ref straight over an EXISTING
196
+ # install would overwrite whatever that ref's git history holds at orchestrations/logs/ (5,268
197
+ # tracked files there, several genuinely real run evidence) — silently discarding a colleague's
198
+ # actual run history on every re-run. run-state-paths.json declares what an update must never
199
+ # touch; a first install into an empty $DEST is unaffected either way.
200
+ _RUN_STATE_EXCLUDES=()
201
+ if [ -f "$INSTALLER_DIR/run-state-paths.json" ]; then
202
+ . "$INSTALLER_DIR/lib/preserve-run-state.sh"
203
+ while IFS= read -r _excl; do
204
+ [ -n "$_excl" ] && _RUN_STATE_EXCLUDES+=("$_excl")
205
+ done < <(run_state_exclude_args "$INSTALLER_DIR/run-state-paths.json")
206
+ fi
207
+ # The ${arr[@]+"${arr[@]}"} form, not bare "${arr[@]}": bash <4.4 (macOS ships 3.2 by default,
208
+ # GPLv3 licensing) throws "unbound variable" under `set -u` expanding an empty array the plain
209
+ # way. This form is safe on every bash this installer might run under.
210
+ if ! git -C "$_GIT_ROOT" archive "$_PKG_REF" \
211
+ | tar -x -C "$DEST" "${_RUN_STATE_EXCLUDES[@]+"${_RUN_STATE_EXCLUDES[@]}"}"; then
212
+ _bad "packaging '$_PKG_REF' into $DEST failed"
213
+ exit 1
214
+ fi
215
+ _ok "packaged $_PKG_REF into $DEST"
216
+ ROOT="$DEST"
217
+ CONFIG="$ROOT/orchestrations/config"
218
+ fi
219
+
220
+ # ── What stacks exist, and which is default? Read, never listed here. ────────
221
+ [ -f "$CONFIG/provider-sets.json" ] || { _bad "no provider-sets.json — is this the repo root?"; exit 1; }
222
+ read -r DEFAULT_SET ALL_SETS <<<"$("$NODE_BIN" -e '
223
+ const j = require(process.argv[1]);
224
+ process.stdout.write((j.defaultSet || "") + " " + Object.keys(j.sets || {}).join(","));
225
+ ' "$CONFIG/provider-sets.json" 2>/dev/null)"
226
+ STACK="${STACK:-${EPAM_PROVIDER_SET:-$DEFAULT_SET}}"
227
+ case ",$ALL_SETS," in
228
+ *",$STACK,"*) : ;;
229
+ *) _bad "unknown stack '$STACK' — declared stacks are: ${ALL_SETS//,/, }"; exit 1 ;;
230
+ esac
231
+
232
+ _head "Stack: $STACK (available: ${ALL_SETS//,/, })"
233
+
234
+ # ── Prerequisites the run genuinely needs ───────────────────────────────────
235
+ _head "Prerequisites"
236
+ need() {
237
+ local cmd="$1" why="$2"
238
+ if command -v "$cmd" >/dev/null 2>&1; then _ok "$cmd"
239
+ else _bad "$cmd is missing — $why"; FAILED=1; fi
240
+ }
241
+ need git "the pipeline works on git codelines"
242
+ need jq "the pipeline parses JSON with it throughout"
243
+ need node "the engine and the CLI are node"
244
+
245
+ # The runner this stack declares. This is the commonest real failure.
246
+ RUNNER="$("$NODE_BIN" -e '
247
+ const fs = require("fs"), path = require("path");
248
+ const reg = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
249
+ const set = reg.sets[process.argv[2]];
250
+ const s = JSON.parse(fs.readFileSync(path.join(path.dirname(process.argv[1]), set.settingsFile), "utf8"));
251
+ process.stdout.write(Object.keys(s.runners || {})[0] || "");
252
+ ' "$CONFIG/provider-sets.json" "$STACK" 2>/dev/null)"
253
+ if [ -n "$RUNNER" ]; then
254
+ if command -v "$RUNNER" >/dev/null 2>&1; then _ok "$RUNNER (the '$STACK' runner)"
255
+ else _bad "'$RUNNER' is not on PATH — the '$STACK' stack cannot run without it"; FAILED=1; fi
256
+
257
+ # PYTHON IS A RUNTIME DEPENDENCY, not an optional extra: 88 handlers under
258
+ # orchestrations/scripts/lib/handlers are executed with it.
259
+ #
260
+ # Measured, because the assumption was bigger than the truth: every import across all 88 is
261
+ # stdlib, plus one LOCAL module (_testfile, imported by siblings in the same directory). So there
262
+ # is no venv to provision, no pip install, no requirements.txt — the interpreter is the whole
263
+ # requirement, and checking for it is the whole job.
264
+ if command -v python3 >/dev/null 2>&1; then
265
+ _ok "python3 ($(python3 -V 2>&1 | awk '{print $2}')) — 88 handlers need it"
266
+ else
267
+ _bad "python3 is not on PATH — 88 pipeline handlers cannot run without it"; FAILED=1
268
+ fi
269
+ fi
270
+
271
+ # ── Credentials: what this stack needs, from what it declares ───────────────
272
+ _head "Credentials"
273
+ # .env.example IS GENERATED, never hand-maintained — it went stale in both directions: this repo's
274
+ # root template never mentioned openrouter's own required OPENROUTER_API_KEY/MINIMAX_API_KEY
275
+ # (declared in provider-sets.json, absent from what an operator was told to fill in), and
276
+ # launch-dashboard's template kept calling EPAM_PROVIDER_SET "REQUIRED" long after that requirement
277
+ # was removed from config.js. Regenerated every install so it cannot drift stale again — .env
278
+ # itself is never touched, only the template.
279
+ if [ "$CHECK_ONLY" = "0" ] && [ -f "$CONFIG/provider-sets.json" ]; then
280
+ . "$INSTALLER_DIR/lib/generate-env-example.sh"
281
+ if generate_env_example "$CONFIG/provider-sets.json" "$CONFIG/env-vars.json" "$ROOT/.env.example" 2>/dev/null; then
282
+ _ok ".env.example regenerated from provider-sets.json"
283
+ else
284
+ _warn "could not regenerate .env.example — using whatever is already there"
285
+ fi
286
+ fi
287
+
288
+ if [ -f "$ROOT/.env" ]; then
289
+ _ok ".env present"
290
+ else
291
+ # THE TEMPLATE IS NAMED .env.example. This read .env.sample — a file that does not exist and
292
+ # never has — guarded by `[ -f ]`, so the guard was false, the copy never happened, and NOTHING
293
+ # WAS SAID. The operator was then told to fill in a file the installer had not created.
294
+ #
295
+ # Resolved from a declared list so a rename cannot silently reintroduce the same no-op, and an
296
+ # ABSENT template is reported by name rather than passed over.
297
+ _tpl=""
298
+ for _c in "$ROOT/.env.example" "$ROOT/.env.sample"; do
299
+ [ -f "$_c" ] && { _tpl="$_c"; break; }
300
+ done
301
+ if [ "$CHECK_ONLY" = "1" ]; then
302
+ _bad ".env is missing"; FAILED=1
303
+ elif [ -n "$_tpl" ]; then
304
+ cp "$_tpl" "$ROOT/.env"
305
+ _warn ".env created from $(basename "$_tpl") — FILL IT IN before running"
306
+ else
307
+ _bad "no .env and no template (.env.example) to create one from"; FAILED=1
308
+ fi
309
+ fi
310
+
311
+ # EXISTENCE IS NOT SUFFICIENCY — the same defect class already fixed for dist/epam.js
312
+ # ("EXISTENCE IS NOT A BUILD, either way"). A copied-but-unfilled .env (still holding the empty
313
+ # placeholders the template ships with) reported "present" and nothing more, so a stack's own
314
+ # REQUIRED credential could sit empty all the way to the first paid call before anyone noticed.
315
+ if [ -f "$ROOT/.env" ] && [ -f "$ROOT/orchestrations/scripts/lib/set-credentials.sh" ]; then
316
+ _missing_creds="$(
317
+ set -a; . "$ROOT/.env" 2>/dev/null; set +a
318
+ . "$ROOT/orchestrations/scripts/lib/set-credentials.sh"
319
+ while IFS=$'\t' read -r _c_env _c_from _c_req; do
320
+ [ "$_c_req" = "1" ] || continue
321
+ eval "_c_val=\${$_c_from:-}"
322
+ [ -z "$_c_val" ] && printf '%s ' "$_c_from"
323
+ done < <(EPAM_PROVIDER_SET="$STACK" _set_credentials_decl 2>/dev/null)
324
+ )"
325
+ if [ -n "$(printf '%s' "$_missing_creds" | tr -d '[:space:]')" ]; then
326
+ _bad "the '$STACK' stack needs these, still empty in .env: $_missing_creds"
327
+ FAILED=1
328
+ else
329
+ _ok "required '$STACK' credentials are filled in"
330
+ fi
331
+ fi
332
+
333
+ # ── Build ───────────────────────────────────────────────────────────────────
334
+ _head "Build"
335
+ # A PACKAGED INSTALL HAS NO src/. That is the artefact this installer exists to install: dist/ and
336
+ # orchestrations/ without the CLI source (§1.4 of the packaging plan). `npm run build` runs tsup,
337
+ # which needs src/ AND the dev dependencies — a client tree has neither, so building
338
+ # unconditionally fails at the one step that cannot be skipped.
339
+ #
340
+ # The rule: build when there is source to build FROM, verify otherwise, and SAY WHICH HAPPENED.
341
+ # Never silently skip; never fail on a tree that is already complete.
342
+ #
343
+ # EXISTENCE IS NOT A BUILD, either way. `[ -f dist/epam.js ]` passes on a 188-byte stub, so the old
344
+ # check reported success for a tree that could not run. The threshold sits far below any real
345
+ # bundle and far above any stub.
346
+ _dist="$ROOT/dist/epam.js"
347
+ _min_bytes="${EPAM_MIN_DIST_BYTES:-51200}"
348
+
349
+ # WHETHER dist/epam.js MATTERS DEPENDS ON THE STACK, and the stack declares it.
350
+ #
351
+ # claude.sh:1649-1650 routes copilot|openai|openrouter|cursor|minimax|epam to $EPAM_CLI
352
+ # (dist/epam.js), and `claude` to $CLAUDE_CMD. Every provider set declares `claude` or
353
+ # `codemie-claude` as its runner, so on those stacks dist/epam.js is NEVER executed — which is why
354
+ # this repo runs green with a 188-byte "Hello, World!" stub in dist/.
355
+ #
356
+ # So a hard failure here would refuse an install that works. It is reported instead, with what it
357
+ # means, and only FAILS when the stack actually routes to the epam CLI. The check that matters for
358
+ # every stack — is the declared RUNNER on PATH — already runs above.
359
+ _verify_dist() {
360
+ local _needs_epam=0
361
+ case "$RUNNER" in
362
+ epam|"") _needs_epam=1 ;;
363
+ esac
364
+
365
+ if [ ! -f "$_dist" ]; then
366
+ if [ "$_needs_epam" = "1" ]; then
367
+ _bad "dist/epam.js is missing and the '$STACK' stack needs it"; FAILED=1; return 1
368
+ fi
369
+ _warn "dist/epam.js is absent — not needed by the '$STACK' stack (runner: $RUNNER)"
370
+ return 0
371
+ fi
372
+
373
+ _size=$(wc -c < "$_dist" 2>/dev/null | tr -d ' ')
374
+ if [ "${_size:-0}" -lt "$_min_bytes" ]; then
375
+ if [ "$_needs_epam" = "1" ]; then
376
+ _bad "dist/epam.js is only ${_size} bytes — that is a stub, not a build, and the '$STACK' stack needs it"
377
+ FAILED=1; return 1
378
+ fi
379
+ _warn "dist/epam.js is a ${_size}-byte stub — harmless for the '$STACK' stack (runner: $RUNNER), but it is not a build"
380
+ return 0
381
+ fi
382
+ _ok "dist/epam.js present (${_size} bytes)"
383
+ return 0
384
+ }
385
+
386
+ if [ ! -d "$ROOT/src" ]; then
387
+ # The packaged case. Stated explicitly so nobody reads "ok" and assumes a build happened here.
388
+ _ok "packaged install — no src/, using the shipped pre-built bundle"
389
+ _verify_dist
390
+ elif [ "$CHECK_ONLY" = "1" ]; then
391
+ _verify_dist
392
+ else
393
+ if [ ! -d "$ROOT/node_modules" ]; then
394
+ _warn "installing dependencies (this takes a minute)"
395
+ (cd "$ROOT" && npm install --silent) || { _bad "npm install failed"; FAILED=1; }
396
+ else _ok "node_modules present"; fi
397
+ if (cd "$ROOT" && npm run build --silent >/dev/null 2>&1); then
398
+ _verify_dist
399
+ else
400
+ _bad "build failed — run 'npm run build' to see why"; FAILED=1
401
+ fi
402
+ fi
403
+
404
+ # ── The `epam` shim ───────────────────────────────────────────────────────────
405
+ # THE SHIM MUST POINT AT THIS INSTALL. The one found on the development machine reads
406
+ # exec node /home/<someone>/projects/ai/epam-cli/dist/epam.js "$@"
407
+ # which is correct for exactly one checkout and wrong for every install. The path is computed here,
408
+ # at install time, from where this script actually is.
409
+ _head "Command"
410
+ BIN_DIR="${EPAM_BIN_DIR:-$HOME/.local/bin}"
411
+ if [ "$CHECK_ONLY" = "1" ]; then
412
+ if [ -x "$BIN_DIR/epam" ]; then _ok "epam shim present at $BIN_DIR/epam"
413
+ else _warn "no epam shim at $BIN_DIR/epam"; fi
414
+ else
415
+ mkdir -p "$BIN_DIR"
416
+ printf '#!/usr/bin/env bash\nexec node "%s/dist/epam.js" "$@"\n' "$ROOT" > "$BIN_DIR/epam"
417
+ chmod +x "$BIN_DIR/epam"
418
+ _ok "epam shim written to $BIN_DIR/epam -> $ROOT/dist/epam.js"
419
+ case ":$PATH:" in
420
+ *":$BIN_DIR:"*) : ;;
421
+ # Said, never done silently: editing a shell profile behind an operator is a surprise, and
422
+ # a shim that is not on PATH is a shim that does nothing.
423
+ *) _warn "$BIN_DIR is not on PATH — add it: export PATH=\"$BIN_DIR:\$PATH\"" ;;
424
+ esac
425
+ fi
426
+
427
+ # ── Project config: the three things that cannot be derived ───────────────────
428
+ # JIRA_URL, JIRA_PROJECT_KEY and JIRA_CODELINE_ROOT are answers, not defaults — nothing in the tree
429
+ # can infer which Jira site, which project, or where the codelines live.
430
+ #
431
+ # NON-SECRET VALUES ONLY. Credentials live in one .env, owned by the operator. This never prompts
432
+ # for a token, never echoes one, and never writes one into project config: one file, one owner, one
433
+ # place to look.
434
+ _head "Project"
435
+ EPAM_PROJECT="${EPAM_PROJECT:-}"
436
+ if [ -n "$EPAM_PROJECT" ] && [ -d "$ROOT/orchestrations/projects/$EPAM_PROJECT" ]; then
437
+ _cfg="$ROOT/orchestrations/projects/$EPAM_PROJECT/config.env"
438
+ if [ "$CHECK_ONLY" = "1" ]; then
439
+ [ -f "$_cfg" ] && _ok "config.env present for '$EPAM_PROJECT'" || _warn "no config.env for '$EPAM_PROJECT'"
440
+ else
441
+ _missing=""
442
+ for _v in JIRA_URL JIRA_PROJECT_KEY JIRA_CODELINE_ROOT; do
443
+ eval "_val=\${$_v:-}"
444
+ [ -z "$_val" ] && _missing="$_missing $_v"
445
+ done
446
+ if [ -n "$_missing" ]; then
447
+ _warn "project '$EPAM_PROJECT' not configured — missing:$_missing"
448
+ else
449
+ {
450
+ echo "# Written by install.sh. NON-SECRET VALUES ONLY — credentials live in .env."
451
+ echo "PROJECT_NAME=$EPAM_PROJECT"
452
+ echo "JIRA_URL=$JIRA_URL"
453
+ echo "JIRA_PROJECT_KEY=$JIRA_PROJECT_KEY"
454
+ echo "JIRA_CODELINE_ROOT=$JIRA_CODELINE_ROOT"
455
+ } > "$_cfg"
456
+ _ok "wrote $_cfg (JIRA_URL, JIRA_PROJECT_KEY, JIRA_CODELINE_ROOT)"
457
+ fi
458
+ fi
459
+ else
460
+ _ok "no project selected (set EPAM_PROJECT to configure one)"
461
+ fi
462
+
463
+ # ── Container runtime ─────────────────────────────────────────────────────────
464
+ _head "Container runtime"
465
+ # ONE RESOLVER, ASKED — not a third copy of the rule. The installer, dashboard-health-check.sh and
466
+ # pre-run-reset.sh each had their own idea of which runtime to use, and only this one had ever
467
+ # heard of podman.
468
+ _CR_LIB="$INSTALLER_DIR/lib/container-runtime.sh"
469
+ if [ -f "$_CR_LIB" ]; then
470
+ # shellcheck source=orchestrations-installer/lib/container-runtime.sh
471
+ . "$_CR_LIB"
472
+ else
473
+ _bad "missing $_CR_LIB — this tree cannot resolve a container runtime"; FAILED=1
474
+ fi
475
+
476
+ _CR_DECLARED="$CONTAINER_RUNTIME"
477
+ if CONTAINER_RUNTIME="$(container_runtime 2>&1)"; then
478
+ if [ -n "$_CR_DECLARED" ]; then _ok "runtime: $CONTAINER_RUNTIME (declared)"
479
+ else _ok "runtime: $CONTAINER_RUNTIME (discovered)"
480
+ fi
481
+ else
482
+ # The resolver's own message says WHICH runtimes it looked for, so it is reported verbatim
483
+ # rather than restated here in words that could drift from the declaration.
484
+ _CR_WHY="$CONTAINER_RUNTIME"
485
+ CONTAINER_RUNTIME=none
486
+ if [ -n "$_CR_DECLARED" ]; then
487
+ # A runtime the installer cannot drive must fail HERE, not surface later as a compose
488
+ # command that does nothing.
489
+ _bad "$_CR_WHY"; FAILED=1
490
+ else
491
+ # Nothing installed is not a failure: --no-docker is a supported install.
492
+ _ok "runtime: none — no container runtime found, the pipeline still runs"
493
+ fi
494
+ fi
495
+
496
+ # ── Replay ────────────────────────────────────────────────────────────────────
497
+ _head "Replay"
498
+ case "$REPLAY_MODE" in
499
+ off)
500
+ # The cost is one-way and must be stated: nothing recorded now can be replayed later.
501
+ _ok "replay: off — runs will NOT be replayable (no Langfuse recorder installed)" ;;
502
+ on)
503
+ _ok "replay: on — Langfuse records every run so it can be replayed for \$0"
504
+ # LangfuseTracer.ts:30 gates on BOTH keys. A fresh install has empty volumes, so no project
505
+ # and no keys exist — and recording is silently off while the containers run and capture
506
+ # nothing. That is the one case where a warning is not enough.
507
+ _lf_missing=""
508
+ [ -z "${LANGFUSE_SECRET_KEY:-}" ] && _lf_missing="$_lf_missing LANGFUSE_SECRET_KEY"
509
+ [ -z "${LANGFUSE_PUBLIC_KEY:-}" ] && _lf_missing="$_lf_missing LANGFUSE_PUBLIC_KEY"
510
+ if [ -n "$_lf_missing" ]; then
511
+ _bad "replay: on but missing:$_lf_missing — nothing would be recorded, and a run not recorded can never be replayed"
512
+ FAILED=1
513
+ else
514
+ _ok "Langfuse keys present — recording is active" ;
515
+ fi ;;
516
+ *)
517
+ _bad "unknown replay mode '$REPLAY_MODE' — expected on or off"; FAILED=1 ;;
518
+ esac
519
+
520
+ # ── Dashboards: OPTIONAL, and never a reason to fail ────────────────────────
521
+ _head "Dashboards (optional)"
522
+ # THE PROBE ASKS THE RESOLVED RUNTIME. It said `docker` literally, so on a podman-only machine the
523
+ # installer announced "runtime: podman" and then started nothing — the report and the behaviour
524
+ # disagreeing, which is this file's recurring defect.
525
+ runtime_up() {
526
+ [ "$CONTAINER_RUNTIME" = "none" ] && return 1
527
+ command -v "$CONTAINER_RUNTIME" >/dev/null 2>&1 && "$CONTAINER_RUNTIME" info >/dev/null 2>&1
528
+ }
529
+
530
+ # THE COMPOSE FILE IS NAMED. This ran `docker compose up -d` with no -f, and there is no
531
+ # docker-compose.yml at the repo root — only the named files below. It ended in `|| true`, so the
532
+ # failure was swallowed and the installer reported "docker is up" having started nothing.
533
+ COMPOSE_FILE="${EPAM_COMPOSE_FILE:-$ROOT/docker-compose.observability.yml}"
534
+ # ISOLATED FROM EVERY OTHER INSTALL ON THIS MACHINE, DETERMINISTICALLY — never a human hand-picking
535
+ # a free subnet. The compose file's own default (EPAM_OBS_SUBNET:-172.31.0.0/16) is a FIXED
536
+ # constant: two installs, or this exact install colliding with an already-running dev checkout on
537
+ # that same default, hit "Pool overlaps with other one on this address space" — the launch-dashboard
538
+ # section already had this fix; the observability stack never did.
539
+ . "$INSTALLER_DIR/lib/isolated-compose-identity.sh"
540
+ _OBS_PROJECT="$(isolated_project_name "$ROOT" obs)"
541
+ compose_up() {
542
+ if [ ! -f "$COMPOSE_FILE" ]; then
543
+ _bad "compose file not found: $COMPOSE_FILE"; FAILED=1; return 1
544
+ fi
545
+ local _up=1 _log _subnet _i=0
546
+ _log="$(mktemp)"
547
+ for _subnet in $(isolated_subnet_candidates "$ROOT"); do
548
+ # ATTEMPT 0 KEEPS THE WELL-KNOWN PORTS EXACTLY (offset 0) — a normal single-install machine
549
+ # sees no change at all, still :3100, :8092, :8123, :3001. Only a genuine collision (this
550
+ # exact stack already running, or a second install) steps to the next attempt's offset, so
551
+ # no manual port flag is ever required for this to just work.
552
+ local _off=$((_i * 10))
553
+ if (cd "$ROOT" && EPAM_OBS_SUBNET="$_subnet" \
554
+ EPAM_OBS_CLICKHOUSE_PORT=$((8123 + _off)) \
555
+ EPAM_OBS_LANGFUSE_PORT=$((3100 + _off)) \
556
+ EPAM_OBS_DASHBOARD_PORT=$((8092 + _off)) \
557
+ EPAM_OBS_GRAFANA_PORT=$((3001 + _off)) \
558
+ container_compose -f "$COMPOSE_FILE" -p "$_OBS_PROJECT" up -d) >"$_log" 2>&1; then
559
+ _up=0
560
+ break
561
+ fi
562
+ # ONLY RETRY ON A SUBNET OR PORT COLLISION — any other failure would fail identically on
563
+ # every candidate, burning through all of them and hiding the real error behind repeats.
564
+ grep -qiE 'overlap|pool|port is already allocated|address already in use' "$_log" || break
565
+ # TEAR DOWN BEFORE THE NEXT ATTEMPT. A failed `up -d` still CREATES whatever services it
566
+ # got to before the failing one — found live: postgres/redis/clickhouse started fine on
567
+ # attempt 0, agent-monitor's port collision failed the overall command, and attempt 1's
568
+ # NEW port env was silently ignored for the already-Created containers, which stayed bound
569
+ # to attempt 0's (colliding) ports. Compose does not cleanly re-resolve an already-created
570
+ # container's config from new env vars on a later `up` — each attempt needs a clean slate.
571
+ (cd "$ROOT" && container_compose -f "$COMPOSE_FILE" -p "$_OBS_PROJECT" down) >/dev/null 2>&1 || true
572
+ _i=$((_i + 1))
573
+ done
574
+ if [ "$_up" != "0" ]; then
575
+ _bad "$CONTAINER_RUNTIME compose failed for $COMPOSE_FILE — the services are NOT running: $(tail -3 "$_log" 2>/dev/null)"
576
+ FAILED=1
577
+ rm -f "$_log" 2>/dev/null
578
+ return 1
579
+ fi
580
+ rm -f "$_log" 2>/dev/null
581
+ return 0
582
+ }
583
+ case "$USE_DOCKER" in
584
+ no) _ok "skipped (--no-docker) — the pipeline runs without them" ;;
585
+ yes) if runtime_up; then
586
+ if [ "$CHECK_ONLY" = "1" ] || compose_up; then _ok "$CONTAINER_RUNTIME is up — services started"; fi
587
+ else _bad "--docker was requested but no container runtime is running (resolved: $CONTAINER_RUNTIME)"; FAILED=1; fi ;;
588
+ auto) if runtime_up; then
589
+ if [ "$CHECK_ONLY" = "1" ] || compose_up; then _ok "$CONTAINER_RUNTIME is up — dashboards available"; fi
590
+ else _warn "no container runtime is running — dashboards unavailable, THE PIPELINE STILL RUNS"; fi ;;
591
+ esac
592
+
593
+ # ── Launch dashboard: OPTIONAL, and must be genuinely UP when it claims to be ─
594
+ # THIS CANNOT DEPEND ON A HUMAN OR AN LLM DOING IT BY HAND. A rebuild-and-restart done manually
595
+ # once is a rebuild-and-restart that must be done manually every time — this makes it the same
596
+ # re-run of install.sh as everything else above.
597
+ #
598
+ # `docker compose up -d` alone does NOT rebuild an image from changed source; it recreates
599
+ # containers from whatever image already exists. --build closes that. And `up -d` exiting 0 means
600
+ # containers were CREATED, not that the service inside is ready to answer a request — closed by
601
+ # actually polling the health endpoint below rather than trusting the exit code.
602
+ _head "Launch dashboard (optional)"
603
+ LAUNCH_DIR="$ROOT/launch-dashboard"
604
+ LAUNCH_COMPOSE="$LAUNCH_DIR/docker-compose.yml"
605
+ LAUNCH_HEALTH_TRIES="${EPAM_LAUNCH_HEALTH_TRIES:-30}"
606
+ LAUNCH_HEALTH_INTERVAL="${EPAM_LAUNCH_HEALTH_INTERVAL:-1}"
607
+ LAUNCH_STATUS=absent
608
+
609
+ if [ ! -f "$LAUNCH_COMPOSE" ]; then
610
+ _ok "not present in this tree — nothing to provision"
611
+ elif [ "$USE_DOCKER" = "no" ]; then
612
+ LAUNCH_STATUS=skipped
613
+ _ok "skipped (--no-docker)"
614
+ else
615
+ . "$INSTALLER_DIR/lib/wait-for-health.sh"
616
+ . "$INSTALLER_DIR/lib/isolated-compose-identity.sh"
617
+
618
+ # A REAL PASSWORD IS A DECISION ONLY A HUMAN MAKES — never synthesized here. Mirrors the root
619
+ # .env handling: copy the template so there is something to fill in, never invent a secret.
620
+ if [ ! -f "$LAUNCH_DIR/.env" ]; then
621
+ if [ -f "$LAUNCH_DIR/.env.example" ]; then
622
+ cp "$LAUNCH_DIR/.env.example" "$LAUNCH_DIR/.env"
623
+ _warn "launch-dashboard/.env created from .env.example — FILL IN LAUNCH_PASSWORD before it can start"
624
+ else
625
+ _bad "launch-dashboard/.env is missing and there is no .env.example to create one from"
626
+ FAILED=1
627
+ fi
628
+ fi
629
+
630
+ _LD_PORT="$(grep -E '^LAUNCH_UI_PORT=' "$LAUNCH_DIR/.env" 2>/dev/null | tail -1 | cut -d= -f2)"
631
+ _LD_PORT="${_LD_PORT:-8099}"
632
+ _LD_PROJECT="$(isolated_project_name "$ROOT" launch)"
633
+ _LD_HEALTH_URL="http://localhost:${_LD_PORT}/api/health"
634
+
635
+ if [ ! -f "$LAUNCH_DIR/.env" ]; then
636
+ LAUNCH_STATUS=failed
637
+ elif [ "$CHECK_ONLY" = "1" ]; then
638
+ if wait_for_health "$_LD_HEALTH_URL" 3 1; then
639
+ LAUNCH_STATUS=up
640
+ _ok "up at $_LD_HEALTH_URL (project: $_LD_PROJECT)"
641
+ else
642
+ LAUNCH_STATUS=not-answering
643
+ _warn "not answering at $_LD_HEALTH_URL"
644
+ fi
645
+ elif ! runtime_up; then
646
+ LAUNCH_STATUS=no-runtime
647
+ _warn "no container runtime is running — launch dashboard unavailable, THE PIPELINE STILL RUNS"
648
+ else
649
+ # ISOLATED FROM EVERY OTHER INSTALL ON THIS MACHINE, DETERMINISTICALLY. Two checkouts (a
650
+ # dev tree and a dogfood copy, say) must both be able to run at once. Retries on a subnet
651
+ # OR a port collision — found live, both are real: a second install's observability stack
652
+ # hit the subnet default, and THIS stack hit LAUNCH_UI_PORT's default (8099) already held
653
+ # by an earlier install on the same machine. Attempt 0 keeps the .env-declared port exactly
654
+ # (no surprise for an operator who set one deliberately); only a genuine collision steps to
655
+ # the next offset. Any OTHER failure (a bad Dockerfile, a missing image) would fail
656
+ # identically on every candidate, burning through all of them and hiding the real error.
657
+ _LD_UP=1
658
+ _LD_LOG="$(mktemp)"
659
+ _LD_SUBNET=""; _LD_I=0
660
+ for _LD_SUBNET in $(isolated_subnet_candidates "$ROOT"); do
661
+ _LD_TRY_PORT=$((_LD_PORT + _LD_I * 10))
662
+ if (cd "$LAUNCH_DIR" && LAUNCH_SUBNET="$_LD_SUBNET" LAUNCH_UI_PORT="$_LD_TRY_PORT" \
663
+ container_compose -f "$LAUNCH_COMPOSE" -p "$_LD_PROJECT" up -d --build) >"$_LD_LOG" 2>&1; then
664
+ _LD_UP=0
665
+ _LD_PORT="$_LD_TRY_PORT"
666
+ _LD_HEALTH_URL="http://localhost:${_LD_PORT}/api/health"
667
+ break
668
+ fi
669
+ grep -qiE 'overlap|pool|port is already allocated|address already in use' "$_LD_LOG" || break
670
+ # TEAR DOWN BEFORE THE NEXT ATTEMPT — same fix as the observability stack's retry
671
+ # loop: a failed `up -d` can still CREATE containers attached to the failed attempt's
672
+ # network/port before the failure, and compose does not cleanly re-attach an
673
+ # already-created container to a DIFFERENT network or port on a later `up`. Each
674
+ # attempt needs a clean slate.
675
+ (cd "$LAUNCH_DIR" && container_compose -f "$LAUNCH_COMPOSE" -p "$_LD_PROJECT" down) >/dev/null 2>&1 || true
676
+ _LD_I=$((_LD_I + 1))
677
+ done
678
+
679
+ if [ "$_LD_UP" = "1" ]; then
680
+ LAUNCH_STATUS=failed
681
+ _bad "launch dashboard failed to start: $(tail -3 "$_LD_LOG" 2>/dev/null)"
682
+ FAILED=1
683
+ elif wait_for_health "$_LD_HEALTH_URL" "$LAUNCH_HEALTH_TRIES" "$LAUNCH_HEALTH_INTERVAL"; then
684
+ LAUNCH_STATUS=up
685
+ _ok "up and healthy at $_LD_HEALTH_URL (project: $_LD_PROJECT, subnet: $_LD_SUBNET)"
686
+ else
687
+ LAUNCH_STATUS=unhealthy
688
+ _bad "containers started but never answered healthy at $_LD_HEALTH_URL"
689
+ FAILED=1
690
+ fi
691
+ rm -f "$_LD_LOG" 2>/dev/null
692
+ fi
693
+ fi
694
+
695
+ # ── The command people will actually type ───────────────────────────────────
696
+ # ── What this install IS ──────────────────────────────────────────────────────
697
+ # An install whose mode can only be inferred from which containers happen to be running is an
698
+ # install nobody can reason about later. install.sh --check reads this rather than re-deriving.
699
+ if [ "$CHECK_ONLY" = "0" ]; then
700
+ cat > "$ROOT/install-manifest.json" <<MANIFEST
701
+ {
702
+ "stack": "${STACK}",
703
+ "runner": "${RUNNER}",
704
+ "containerRuntime": "${CONTAINER_RUNTIME}",
705
+ "dashboards": "${USE_DOCKER}",
706
+ "replay": "${REPLAY_MODE}",
707
+ "project": "${EPAM_PROJECT:-}",
708
+ "installedAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
709
+ "installRoot": "${ROOT}",
710
+ "launchDashboard": "${LAUNCH_STATUS}"
711
+ }
712
+ MANIFEST
713
+ fi
714
+
715
+ _head "Result"
716
+ if [ "$FAILED" = "1" ]; then
717
+ _bad "install incomplete — fix the items marked ✗ above"
718
+ exit 1
719
+ fi
720
+ _ok "ready"
721
+ printf '\n Run a ticket: %s\n' "./orchestrations/scripts/pipeline --jira ABC-1234"
722
+ printf ' Check first: %s\n' "./orchestrations/scripts/pipeline --jira ABC-1234 --dry-run"
723
+ printf ' Switch stack: %s\n\n' "EPAM_PROVIDER_SET=<${ALL_SETS//,/|}> ./orchestrations/scripts/pipeline --jira ABC-1234"
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "amsd-pipeline",
3
+ "version": "1.9.0",
4
+ "description": "Installer for the amsd-pipeline orchestration stack. Clones, packages and provisions the full stack with one command — no separate git clone step.",
5
+ "bin": {
6
+ "amsd-pipeline": "bin/amsd-pipeline.js"
7
+ },
8
+ "files": [
9
+ "bin/",
10
+ "install.sh"
11
+ ],
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/dune94/epam-cli.git"
15
+ },
16
+ "license": "MIT",
17
+ "engines": {
18
+ "node": ">=18"
19
+ }
20
+ }