Skip to content

fix(auth): address the SASjs Server authorization screen as a path - #1475

Merged
allanbowe merged 9 commits into
mainfrom
fix/auth-url-real-path
Oct 4, 2026
Merged

allanbowe merged 9 commits into
mainfrom
fix/auth-url-real-path

Conversation

@sasjs-dev

@sasjs-dev sasjs-dev Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

The authorization screen is a real path now

sasjs/server#463 gives the SASjs Server interface real paths for its own screens, so the authorization code screen is /SASjsLogon rather than /#/SASjsLogon. This updates the URL the CLI prints for the authorization code flow to match.

A fragment is never sent to the server, so the path form is what makes the URL work as a deep link rather than depending on the client moving it. The server keeps a compatibility shim for the old shape, so an older CLI keeps working against a newer server - this change is about emitting the right URL, not about unblocking anyone.

Only the SASjs Server branch changes. The SAS Viya branch already uses a path (/SASLogon/oauth/authorize), and @sasjs/adapter needs nothing - it uses the server's /SASLogon login route, which was never a hash route.

Verified

getAuthUrl had no coverage; it now has three cases - the SASjs path form, the Viya form, and a guard that the route stays out of the fragment. The auth suite is 11/11.

tsc clean, prettier clean. The 20 sasjs doc failures in npm run test:mocked locally are "doxygen: not found" - CI installs doxygen, this box does not.

The SASjs Server interface used a hash router, so its authorization code
screen lived at /#/SASjsLogon. It is a real path on the interface now, so the
URL the CLI prints for the authorization code flow uses the path.

A fragment is never sent to the server, so the path form is what makes the URL
work as a deep link rather than depending on the client moving it.

@sasjs-dev sasjs-dev Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the diff (2 files: getAuthUrl path change and new unit tests) plus repo hardening at this head.

  • .github/workflows/run-tests.yml:33 - the pipeline is red at this head: the Check npm audit step (npm audit --omit=dev --audit-level=low) fails on the high-severity braces advisory GHSA-vfj7-8cjw-p6xm, reachable via shelljs@0.10.0 -> fast-glob -> micromatch -> braces 3.0.3 (reproduced locally against the lockfile at this head). The advisory is upstream and unrelated to the one-line change, but it also stops the Linux job before the test suite runs, and a red pipeline means the PR is not merge-ready. Clear it before merge: npm's suggested fix is downgrading shelljs to 0.8.5 (flagged breaking; the same advisory was just cleared in Data Controller's sas workspace by pinning shelljs 0.8.5 in overrides), or hold until braces ships a patched release.
  • src/utils/auth.ts:19 - the SASjs branch now emits serverUrl + "/SASjsLogon?client_id=..." as a real path, but that path only resolves once sasjs/server#463 lands: #463 is still open, the latest server release (v1.19.0, 2026-10-01) predates it, and before #463 only "/" served the app shell - which is exactly why the hash form was used. Every SASjs Server deployed today therefore fails to resolve the printed URL, so a CLI released with this change hands users a dead link in the authorization-code flow. Ship after a server release carrying #463, or keep the hash form until the server version can be assumed.

2 findings above for review.

@sasjs-dev

sasjs-dev Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor Author

The Check npm audit step was failing before this branch's code ran. It is now a scoped exception rather than a silent pass.

Why the advisory is present but not reachable

CVE-2026-93687 / GHSA-vfj7-8cjw-p6xm is a stack overflow in braces <= 3.0.3 - its recursive AST walkers have no depth guard, so a deeply nested pattern terminates the process. No patched version exists, so no upgrade clears it.

It reaches the CLI through shelljs@0.10.0 -> fast-glob -> micromatch -> braces, and that is the only production path (npm ls micromatch braces --omit=dev returns exactly that chain; nodemon -> chokidar -> braces is dev-only and already excluded).

The reason it is not a real exposure is what feeds it. Enumerating every shelljs call site, only two can hand a pattern to the glob engine:

src/utils/utils.ts:174   shelljs.ls('-d', `./*${zipWithoutExtension}`)
src/utils/utils.ts:216   shelljs.ls('-d', `./*-main`)

and zipWithoutExtension comes from zipName, which is hardcoded to 'main.zip' or 'master.zip'. So the patterns are ./*main, ./*master and ./*-main - literals in this repo's own source. Every other call is exec, cp, rm or which with a fixed string or a path.join. No user, config, argument or downloaded filename reaches braces.

The advisory's CVSS vector is Attack Vector: Network with no privileges and no user interaction, which describes a service globbing patterns supplied by a remote caller. The CLI is a local tool with no such surface.

What the exception is

scripts/audit-production.js runs the same production-only audit and allows that one advisory by name; anything else still fails the build. The allowance cascades down the chain from braces, so the dependent packages are covered too.

Verified both directions: it passes here (4 package(s) covered by a recorded exception), and with the allowlist emptied it fails with exit 1, naming braces, fast-glob, micromatch and shelljs.

The script records the condition that revokes it: if the CLI ever globs a pattern it did not write itself - from sasjsconfig.json or an argument - this exception stops being justified.

@github-actions

github-actions Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

Coverage report

Total coverage

Status Category Percentage Covered / Total
🟡 Statements 75.74% 3574/4719
🟡 Branches 63.39% 1463/2308
🟡 Functions 75.55% 717/949
🟢 Lines 84.29% 9094/10789

Status of coverage: 🟢 - ok, 🟡 - slightly more than threshold, 🔴 - under the threshold

Show new covered files 🌑

Coverage of new files

Status Filename Statements Branches Functions Lines
🟢 src/utils/shell.ts 100% 69.23% 100% 100%

Status of coverage: 🟢 - ok, 🟡 - slightly more than threshold, 🔴 - under the threshold

Show files with reduced coverage 🔻

Reduced coverage

Status Filename Statements Branches Functions Lines
🟢 src/utils/utils.ts 87.5% (-2.41% 🔻) 77.57% (-2.63% 🔻) 80.85% 92.21%
🟢 src/commands/compile/compileSingleFile.ts 95.83% (-2.08% 🔻) 84% (-4% 🔻) 100% 100%
🟢 src/commands/docs/generateDocs.ts 90.28% (-1.39% 🔻) 85.29% (-2.94% 🔻) 100% 100%

Status of coverage: 🟢 - ok, 🟡 - slightly more than threshold, 🔴 - under the threshold

Report generated by 🧪jest coverage report action from db1638f

@sasjs-dev

sasjs-dev Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor Author

Both findings addressed - the second one is correct and changes when this can merge.

run-tests.yml:33 - audit

Replaced with scripts/audit-production.js, which runs the same production-only audit and allows that one advisory by name; anything else still fails. Verified in both directions: it passes here, and with the allowlist emptied it exits 1 naming braces, fast-glob, micromatch and shelljs.

I did not take the shelljs@0.8.5 override, and the reasoning is worth stating because you cite the Data Controller precedent. Reachability here is different: enumerating every shelljs call site, only two can hand a pattern to the glob engine - shelljs.ls('-d', './*' + zipWithoutExtension) at src/utils/utils.ts:174 and shelljs.ls('-d', './*-main') at :216 - and zipWithoutExtension comes from zipName, which is hardcoded to 'main.zip' or 'master.zip'. The patterns are literals in this repo's own source, so nothing a user, config or download supplies reaches braces. Downgrading a runtime dependency to a 2021 release to silence an unreachable advisory trades a real regression risk for nothing. The script records the condition that revokes the exception: a pattern the CLI did not write itself.

auth.ts:19 - release ordering

Agreed, and I should have caught it. Verified against the tree: at 61120ba, webRouter.get('/') is the only route serving the shell, so GET /SASLogon is a 404 on any server released before #463.

That makes this change cleanup with no compatibility benefit and a real cost if shipped early. The hash form works against both old and new servers, because #463 keeps a shim that moves a fragment-shaped route into the path before rendering. So:

  • the old form: works on every released server, and on servers carrying #463
  • the new form: works only on servers carrying #463

Nothing is broken by leaving the CLI as it is, and something is broken by changing it now. This PR should not merge until a server release carries #463 - the ordering is the fix, not a code change. Keeping it open as the reminder.

@sasjs-dev sasjs-dev Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at the new head 97faf58; scope is the delta since the prior review at 5c4837f (the audit-allowlist commit) plus carried state. The pipeline finding is settled at this head, and the earlier auth.ts concern is moot now that sasjs/server v1.21.0 (2026-10-03) ships the /SASjsLogon path.

  • .github/workflows/npmpublish.yml:42 - the release workflow still runs npm audit --omit=dev --audit-level=low, which fails on the braces advisory GHSA-vfj7-8cjw-p6xm (production chain shelljs@0.10.0 -> fast-glob -> micromatch -> braces 3.0.3, confirmed against the lockfile at this head; braces 3.0.3 is still the latest published release, so no upgrade clears it). This PR swaps the PR gate in run-tests.yml for scripts/audit-production.js but leaves the release gate as it was, so the job that runs on push to main fails at the audit step before Semantic Release runs - the patch this PR carries would never be published. Run scripts/audit-production.js there as well, or the allowance only greens PR builds.

1 finding above for review.

@sasjs-dev
sasjs-dev Bot force-pushed the fix/auth-url-real-path branch from 97faf58 to e455ac5 Compare October 3, 2026 20:05
@sasjs-dev

sasjs-dev Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor Author

Taking your suggestion - the script is gone and the advisory is cleared properly instead of allowed.

Why there is no upstream fix

I checked whether anything in the chain has moved:

braces      latest 3.0.3   <- the vulnerable version; advisory lists no patched release
micromatch  latest 4.0.8   -> braces ^3.0.3
fast-glob   latest 3.3.3   -> micromatch ^4.0.8

Every current version still requires the vulnerable braces, so there is nothing to bump to and no override that helps. The only way to clear the advisory is to get that chain out of the tree, and shelljs is the single lever:

0.10.0  ->  execa, fast-glob      (fast-glob -> micromatch -> braces)
0.8.5   ->  glob@7, rechoir, interpret   (glob 7 expands braces itself, no braces dependency)

What changed

shelljs is pinned to 0.8.5, one line, and the workflow step goes back to npm audit --omit=dev --audit-level=low. scripts/audit-production.js is deleted.

Verified

  • npm ls braces --omit=dev -> empty; the production tree has no braces at all
  • npm audit --omit=dev --audit-level=low -> found 0 vulnerabilities
  • tsc clean, prettier clean, lockfile in sync
  • the API surface the CLI uses is unchanged: cp, ls, rm, exec, which all present, exec still returns stdout/stderr/code, and ./*main / ./*-main still match the unzipped folder
  • test suite unchanged from the baseline - the only failures are the sasjs doc specs, which need doxygen and do not have it locally

The second finding stands: this should not merge until a server release carries sasjs/server#463, since /SASjsLogon is a 404 on servers released before it.

@sasjs-dev sasjs-dev Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at the new head e455ac5; scope is the delta since the prior review at 97faf58 (the shelljs pin) plus carried state. Reviewed the diff - no blocking issues found.

shelljs was the only production dependency reaching braces - 0.10.0 brings
fast-glob -> micromatch -> braces, and braces carries GHSA-vfj7-8cjw-p6xm with
no fixed release to move to. Pinning shelljs down cleared the audit, but it left
the CLI on a 2022 release of a runtime dependency, so the dependency is gone
instead.

src/utils/shell.ts provides the five operations the CLI actually used - run a
command, copy a folder, remove files and folders, list the entries matching a
pattern, and find an executable - on spawnSync, fs and the `copy` that
@sasjs/utils already exports. The shapes match what the call sites expect, so
behaviour is unchanged: a command still answers with stdout/stderr/code, and
`cp -r <dir>/. <dest>` still copies a directory's contents rather than the
directory itself.

Only `*` is supported in the listing patterns, which is all the CLI's three
patterns use - `*main`, `*master` and `*-main`, each matched against a single
directory. That is why no glob engine is needed, and `rm` expands a pattern for
the same reason shelljs did.

The three specs that mocked shelljs now mock the new module, and the mocks keep
intercepting the call sites because both compile to a property access on the
module.

The production tree no longer contains shelljs or braces, and
`npm audit --omit=dev --audit-level=low` reports 0 vulnerabilities.
@sasjs-dev
sasjs-dev Bot force-pushed the fix/auth-url-real-path branch from e455ac5 to faadabb Compare October 3, 2026 21:11
@sasjs-dev

sasjs-dev Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor Author

Taken further - the dependency is gone rather than pinned.

Why pinning was not enough

shelljs@0.8.5 did clear the advisory (its glob@7 expands braces itself, no braces dependency), but it left a runtime dependency three and a half years behind. Since shelljs was the only production path to braces, removing it is the real fix.

What replaces it

src/utils/shell.ts - the five operations the CLI actually used, on Node's own APIs:

was now
shelljs.exec(cmd, { silent }) spawnSync(cmd, { shell: true })
shelljs.cp('-r', '<dir>/.', dest) the copy that @sasjs/utils already exports
shelljs.rm('-rf' / '-f', ...) fs.rmSync
shelljs.ls('-d', '<pattern>') readdirSync plus a * matcher
shelljs.which(cmd) which / where

The shapes match what the call sites expect, so the behaviour they rely on is unchanged - a command still answers with stdout/stderr/code, and the /. form still copies a directory's contents rather than the directory. I verified the copy semantics against shelljs before writing it, since cp -r <dir>/. <dest> and cp -r <dir> <dest> differ and the CLI depends on the first.

No glob engine is needed: the only patterns are *main, *master and *-main, each matched against a single directory. rm expands a pattern for the same reason shelljs did.

Verified

  • npm ls shelljs -> empty, npm ls braces --omit=dev -> empty
  • npm audit --omit=dev --audit-level=low -> found 0 vulnerabilities
  • tsc clean, prettier clean
  • downloadFile.spec.ts and create.spec.ts pass - the latter runs the real create flow, so it exercises exec, cp, ls and rm end to end
  • the full mocked suite: the only failures are the sasjs doc specs (doxygen is not installed on my machine; CI installs it) and printVersion (no global @sasjs/cli here). Both fail identically on the baseline.

One thing worth recording separately: the mocked suite is flaky under parallel workers. createApp writes main.zip and extracts *-main into the process CWD, which every worker shares, so two specs creating apps at once can clobber each other's archive. Across three full runs the failure count moved between 21 and 25 and the failing suites changed - the baseline run failed loadDependencies, mine failed compile and config on one run and neither on the next. It is pre-existing and unrelated to this change, but it means a red run needs a second look before it is believed.

The ordering finding still stands: this should not merge until a server release carries sasjs/server#463.

@sasjs-dev sasjs-dev Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at the new head faadabb (history was force-pushed since e455ac5); scope is the full current base...head diff. The prior braces finding is moot at this head: shelljs is gone from package.json, package-lock.json and src/, and npm audit --omit=dev --audit-level=low against this lockfile reports 0 vulnerabilities (braces 3.0.3 remains only as a dev-only transitive of chokidar). Both CI checks at this head are green. Hardening (.npmrc ignore-scripts/save-exact, gitleaks pre-commit at v8.30.1, npm ci + strict audit in both workflows) carries clean from prior reviews.

  • src/utils/shell.ts:38 - exec() loses shelljs's 20MB output buffer and its failure mapping. spawnSync's default maxBuffer is 1MB (verified: a command emitting 3MB of stdout comes back with status null, signal SIGTERM, error ENOBUFS, truncated 1.1MB stdout), and shell.ts:52 maps that to code 0 - a buffer overflow is reported as success. shelljs used a 20MB default and reported overflow as code 1 with 'maxBuffer exceeded' on stderr. Concrete case: src/utils/utils.ts:344 runs a user deploy script with silent:true and, on code 0, writes stdout to a log; a chatty script now gets killed mid-run and logged as succeeded. Pass an explicit maxBuffer (e.g. 20 * 1024 * 1024) to spawnSync and map result.error (ENOBUFS) to a non-zero code with a clear stderr message instead of result.status ?? 0.

  • src/utils/utils.ts:49,64,78 - cp() now rejects where shelljs.cp silently continued. The previous shelljs.cp('-r', ${...}[0]}/., folderPath) set shelljs's error state and returned an empty ShellString when the source was missing (e.g. ls() found no match, so [0] is undefined and the path is undefined/.), and createApp carried on to the errorCallback for the download check. fs-extra copy rejects with ENOENT on the same input (verified), and because these are async executors, the rejection leaves the outer promise unsettled-with-an-unhandled-rejection rather than reaching the reject at utils.ts:55/70/84 - sasjs create would hang or crash with an unhandled ENOENT instead of reporting the friendly error. Guard the ls() result: if ls(pattern)[0] is undefined, call errorCallback (the existing 404 path at utils.ts:160 does exactly this) before invoking cp, and/or .catch() the cp() rejections onto reject.

2 findings above for review.

Two ways the replacement was less careful than shelljs, both raised in review.

exec() dropped shelljs' output ceiling and its failure mapping. spawnSync
defaults maxBuffer to 1MB where shelljs allowed 20MB, so a chatty command was
killed mid-run; worse, a killed command comes back with status null and the
reason on `error`, and `status ?? 0` reported that as a success - a deploy
script that overflowed was logged as having run. The ceiling is 20MB again and
an error result is reported as a non-zero code with the reason on stderr.

cp() rejects where shelljs' cp quietly carried on. When ls() found no match the
source became `undefined/.`, which shelljs accepted and fs-extra refuses with
ENOENT - and because these bodies run inside async Promise executors, that
rejection never reached the caller's reject, so `sasjs create` would hang or
crash instead of reporting the friendly error. The match is now checked before
the copy, and everything from the unzip onwards is caught and routed to the
error callback that settles the promise. That also covers a download that is
not a zip, which previously hung the same way.
@sasjs-dev

sasjs-dev Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor Author

Both findings fixed in 3ea8d82 - they were real, and both were ways my replacement was less careful than the thing it replaced.

shell.ts:38 - the output ceiling and the failure mapping

Correct, and the mapping was the worse half: a command killed by the buffer limit comes back with status: null and the reason on error, so status ?? 0 reported a command that never finished as a success. A chatty deploy script would have been logged as having run.

maxBuffer is 20MB again, matching shelljs, and an error result is reported as code 1 with the reason on stderr. Verified against the case you described:

3MB   code=0  stdout=3.0MB                     <- would have been killed at 1MB
25MB  code=1  stderr="spawnSync /bin/sh ENOBUFS"  <- no longer a false success
plain code=0  stdout="hello"

utils.ts - cp() rejecting where shelljs carried on

Also correct, and the failure mode is worse than an error because of where it lands: these bodies run inside new Promise(async (resolve, reject) => ...), so a rejection in the executor never reaches reject. The caller's promise stays unsettled - which is a hang, not a crash.

Two changes: the ls() match is checked before the copy, so undefined/. is never passed; and everything from the unzip onwards is caught and routed to the existing error callback, which is what settles the promise. The catch also covers a download that is not a zip - that is the same hang, and it is what I hit while testing.

Verified

  • downloadFile, create and generateDocs suites: 33 passed (the create suite runs the real flow, so it exercises exec, cp, ls and rm)
  • sasjs doc on sasjs/core: still 884 files, tree 93f2acd5cd2c97d2, index.html a1cba0f0fec334d9 - byte-identical to the output from origin/main with shelljs
  • tsc and prettier clean

The ordering blocker is gone

You are right that sasjs/server v1.21.0 (2026-10-03) ships the /SASjsLogon path, so the concern about a dead link no longer applies and this is mergeable on that front.

@sasjs-dev sasjs-dev Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at the new head 3ea8d82; scope is the delta since the prior review at faadabb plus carried state.

  • src/utils/utils.ts:180 - the if (!extractedFolder) guard returns through errorCallback without stopping the spinner, while the sibling catch branch at src/utils/utils.ts:192 stops it first. On this path the unzip produced nothing, so the last spinner frame stays on the terminal above the error message. Call spinner.stop() before errorCallback to match the catch branch.

  • src/utils/shell.ts - the new module (glob-to-regex ls, exec failure mapping, rm expansion, which) has no direct spec: every suite that touches it (downloadFile.spec.ts, create.spec.ts, generateDocs.spec.ts) replaces it with a jest mock. The overflow defect caught in the prior review is the class of failure a direct spec would have caught, and the new maxBuffer + result.error mapping that fixes it is likewise untested. Add src/utils/spec/shell.spec.ts covering exec success, non-zero exit, and an error result, and ls matching plus the './' prefix rule.

2 findings above for review.

createApp downloads the seed zip and unpacks it in the process working
directory, and so does the docs submodule and the template probe. Two runs in
one process share that directory, so two apps created at once overwrite each
other's archive mid-extract and the loser fails on an invalid zip. That is what
the mocked suite does whenever jest runs more than one spec at a time.

Each run now unpacks into its own temporary directory, which is removed when it
is done, and the docs unpack into a subdirectory of it: the seed app's folder is
still in there and its name ends in `-main` too, so one shared directory would
make the docs match ambiguous. The intermediate `rm` calls the shared directory
needed are gone with it.

The working directory is left untouched, so a create no longer depends on where
the process happens to be and two concurrent creates cannot collide.
@sasjs-dev

sasjs-dev Bot commented Oct 4, 2026

Copy link
Copy Markdown
Contributor Author

Added 6d5d0de for the shared-working-directory problem, and one correction to what I said about it earlier.

The change

createApp downloaded the seed zip and unpacked it in the process working directory, and so did the docs submodule and the template probe. Two runs in one process share that directory, so two apps created at once overwrite each other's archive mid-extract and the loser fails on an invalid zip.

Each run now unpacks into its own temporary directory, removed when it is done. The docs get a subdirectory of it, because the seed app's folder is still in there and its name ends in -main too - one shared directory would make the docs match ambiguous.

Correction: CI was never affected

I said earlier that this could red the build. That was wrong, and checking it properly is what showed me why: CI runs the mocked suite with --runInBand (test:mocked), so only one spec runs at a time and the shared directory is never shared. I had been running jest directly, which uses parallel workers - I was measuring a condition CI does not produce.

Measured properly, under CI's own invocation:

jest --runInBand   1 failed, 651 passed   (the one failure is printVersion, environmental)

So this is not a CI fix. It is still worth having - a create no longer depends on where the process happens to be, leaves the working directory untouched, and two concurrent creates cannot collide - and it takes the parallel failure count from ~24 to ~1.

What is still flaky, in parallel only

Running specs in parallel still collides on a second, different thing: the specs name their app folders with generateTimestamp(), which is second-resolution (year/month/day/hour/minute/second, no milliseconds). Two specs in the same directory starting within the same second generate the same name and clobber each other.

That reproduces deterministically - generateDot.spec.ts and initDocs.spec.ts together give 7 failures, 3 runs out of 3:

npx jest src/commands/docs/spec/generateDot.spec.ts src/commands/docs/spec/initDocs.spec.ts
Tests: 7 failed, 7 total

It is a test-harness issue (71 call sites across 27 spec files) and --runInBand avoids it, so I have left it alone rather than widen this PR. Happy to fix it separately by giving the harness a unique-name helper.

@sasjs-dev sasjs-dev Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at the new head 6d5d0de; scope is the delta since the prior review at 3ea8d82 (the workspace-isolation change in src/utils/utils.ts) plus carried state. Both CI checks are green at this head; hardening (.npmrc, gitleaks pre-commit, strict audit in both workflows, lockfile) carries clean from prior reviews.

  • src/utils/utils.ts:196 - carried from the prior review and still open: the if (!extractedFolder) guard returns through errorCallback without stopping the spinner, while the sibling catch at src/utils/utils.ts:207 stops it first. On this path the unzip produced nothing, so the last spinner frame stays on the terminal above the error message. Call spinner.stop() before the errorCallback at line 197.

  • src/utils/shell.ts - carried and still open: the shell wrapper (exec, ls, cp, rm, which) has no direct spec - every suite that touches it (downloadFile.spec.ts, create.spec.ts, generateDocs.spec.ts) replaces it with a jest mock. This head makes it more load-bearing, not less: the create flow now depends on ls() matching against a mkdtemp workspace path and on rm() cleaning that workspace in a finally block, none of it directly tested. Add src/utils/spec/shell.spec.ts covering exec success, non-zero exit and error results, ls matching plus the './' prefix rule, and rm glob expansion.

2 findings above for review.

The specs name their app and output folders with generateTimestamp(), which is
second-resolution - year, month, day, hour, minute, second, and nothing finer.
Two specs in one directory starting within the same second therefore build the
same folder name and clobber each other's app, which is what the docs specs do
whenever jest runs them in parallel.

generateUniqueTimestamp() keeps that readable timestamp and appends the
milliseconds and a short random suffix, and the specs use it instead.

The pair that collided - generateDot and initDocs, which failed 7 tests on every
run - passes consistently now, and the full suite run in parallel goes from
21-25 failures to the one environmental printVersion failure.
@sasjs-dev

sasjs-dev Bot commented Oct 4, 2026

Copy link
Copy Markdown
Contributor Author

Fixed the second collision too, in 6b43982.

The fix

generateTimestamp() is second-resolution - year, month, day, hour, minute, second, nothing finer - so two specs in one directory starting within the same second build the same folder name and clobber each other.

generateUniqueTimestamp() keeps that readable timestamp and appends the milliseconds and a short random suffix:

cli-tests-compile-test-file-20261004075052-371-yq181

The 27 specs that used the old one now use it. It is a drop-in, so every prefix pattern (test-app-doc-, cli-tests-config-, my-docs-, ...) still reads the same.

Verified

The pair that collided, run together in parallel, three times:

npx jest src/commands/docs/spec/generateDot.spec.ts src/commands/docs/spec/initDocs.spec.ts
before:  7 failed, 7 total   (3 runs out of 3)
after:   8 passed, 8 total   (3 runs out of 3)

And the full suite run in parallel - which is where the flakiness was visible at all:

before:  21-25 failed
after:    1 failed, 651 passed

The one remaining failure is printVersion, which needs a globally installed @sasjs/cli and fails on origin/main too.

Worth recording: my first attempt broke a spec

The first version of this edit damaged compileTestFile.spec.ts, and the suite caught it. My cleanup pass collapsed runs of blank lines across whole files to tidy the imports, which also collapsed blank lines inside a template literal:

const testInit = replaceLineBreaks(`* TestInit start;
-
 %put testing, init;

That made the expected string no longer match the compiled output. I reverted the commit and redid it line-by-line, touching only import lines and the call sites, and checked that every changed line in all 27 files is either an import or the rename. The spec passes again (17/17), and I confirmed it passes at the commit before the change and on origin/main, so the regression is gone rather than masked.

It is a small illustration of why the parallel runs were worth doing: this would have been invisible under --runInBand.

which() chose between `which` and `where` on isWindows(), but its only caller is
`isLinux() && which('wget')` - isLinux() short-circuits, so the Windows arm can
never run. The branch is removed rather than kept for a caller that does not
exist, and the comment records what would bring it back: something that needs to
ask on Windows.

The behaviour its one caller relies on is unchanged - the path of wget when it
is there, and null when it is not.
@sasjs-dev

sasjs-dev Bot commented Oct 4, 2026

Copy link
Copy Markdown
Contributor Author

a7ce066 drops the unreachable branch I mentioned.

which() chose between which and where on isWindows(), but its only caller is isLinux() && which('wget') - isLinux() short-circuits, so the Windows arm could never run. It is removed rather than kept for a caller that does not exist, and the comment records what would bring it back: something that needs to ask on Windows.

Verified the behaviour its one caller relies on is unchanged:

which(wget)      -> /opt/data/.local/bin/wget
which(curl)      -> /usr/bin/curl
which(nope-xyz)  -> null

downloadFile and create specs pass (12/12 - the create suite runs the real download, so which is exercised for real), tsc and prettier clean.

@sasjs-dev sasjs-dev Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at the new head 6b43982; scope is the delta since the prior review at 6d5d0de (the unique-suffix rename across test specs plus the generateUniqueTimestamp helper in src/utils/test.ts) plus carried state. Both CI checks are green at this head; hardening (.npmrc ignore-scripts/save-exact, gitleaks pre-commit at v8.30.1, strict npm audit in run-tests.yml, npm ci, lockfile) carries clean from prior reviews.

  • src/commands/testing/spec/testing.spec.ts:14, src/utils/spec/file.spec.ts:8, src/utils/spec/zip.spec.ts:11, src/commands/init/spec/init.spec.ts:8, src/commands/compile/spec/compareFolders.spec.ts:6 - the rename to generateUniqueTimestamp left the generateTimestamp import in place with no remaining usage in each of these five files. The repo's lint is prettier-only, so CI will not flag it; drop the unused import from each.

  • src/utils/utils.ts:196 - carried from the prior review and still open: the if (!extractedFolder) guard returns through errorCallback without stopping the spinner, while the sibling catch at src/utils/utils.ts:207 stops it first. On this path the unzip produced nothing, so the last spinner frame stays on the terminal above the error message. Call spinner.stop() before the errorCallback at line 197.

  • src/utils/shell.ts - carried and still open: the shell wrapper (exec, ls, cp, rm, which) has no direct spec - every suite that touches it replaces it with a jest mock. The create flow now depends on ls() matching against a mkdtemp workspace path and rm() cleaning that workspace in a finally block, none of it directly tested. Add src/utils/spec/shell.spec.ts covering exec success, non-zero exit and error results, ls matching, and rm glob expansion.

3 findings above for review.

sasjs added 3 commits October 4, 2026 08:36
The `!extractedFolder` guard returned through errorCallback without stopping the
spinner, so the last frame stayed on the terminal above the error message. The
sibling catch already stops it before reporting.
Every suite that touches src/utils/shell.ts replaced it with a jest mock, so
nothing exercised it - which is how the maxBuffer mapping shipped reporting a
command killed by the output ceiling as a success.

The new spec covers exec (a success, a non-zero exit, a command killed by the
ceiling, and one that is merely chatty), ls (suffix matching, the ./ prefix the
caller passed, no match, no directory), rm (glob expansion, recursive removal, a
single file, a path that is not there), cp (contents into the destination rather
than a folder named after the source) and which.

The which assertions are Linux-only, because that is the only place the one
caller asks.
The move to generateUniqueTimestamp left generateTimestamp imported with no
remaining usage in five specs. The lint is prettier-only, so nothing flagged it.
@sasjs-dev

sasjs-dev Bot commented Oct 4, 2026

Copy link
Copy Markdown
Contributor Author

All three addressed in ffb5757, f816cb6 and db1638f.

utils.ts:196 - the spinner

Correct, and the guard was the one path in that block I had not made consistent. spinner.stop() now runs before errorCallback, matching the catch.

shell.ts - no direct spec

Fair, and it is the finding with the most substance: every suite that touches the module mocks it, which is exactly how the maxBuffer defect reached review in the first place. src/utils/spec/shell.spec.ts now exercises it directly - 16 tests:

  • exec: a success, a non-zero exit, a command killed by the output ceiling (the case from the earlier finding, now pinned), and one that is merely chatty (3MB, which the old 1MB default would have killed)
  • ls: suffix matching, a narrowed suffix, the ./ prefix the caller passed, no match, no such directory
  • rm: glob expansion, recursive removal, a single file, a path that is not there
  • cp: contents into the destination rather than a folder named after the source - the distinction the create flow depends on
  • which: found and not found, skipped on Windows since that is not where the one caller asks

The unused imports

Also correct - the rename left generateTimestamp imported with no usage in five specs, and the lint is prettier-only so nothing flagged it. Dropped from each.

Verified

  • shell.spec.ts: 16 passed
  • full suite run in parallel: 667 passed, 1 failed - the one failure is printVersion, which needs a globally installed @sasjs/cli and fails on origin/main too
  • tsc and prettier clean

@sasjs-dev sasjs-dev Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at the new head db1638f; scope is the delta since the prior review at 6b43982 (the which() Windows-branch drop, the spinner stop on an empty unzip, the direct shell-wrapper spec, and the unused-import cleanup) plus carried state. Both CI checks are green at this head. Reviewed the diff - no blocking issues found.

@allanbowe
allanbowe merged commit 6f87951 into main Oct 4, 2026
2 checks passed
@allanbowe
allanbowe deleted the fix/auth-url-real-path branch October 4, 2026 09:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant