From 31cbfc05333c0eca5336eff42e4289a11e40de63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Thu, 2 Jul 2026 00:56:45 +0200 Subject: [PATCH] feat(push): warn on npm --omit=dev + tsc build script (typescript compilation trap) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A common packaging mistake when moving a local TypeScript Actor to the Apify platform: a single-stage Dockerfile runs `npm install --omit=dev` (or `--production`), then `npm run build` shells out to `tsc` — but `typescript` lives in devDependencies, so it was just dropped. The platform-side Docker build fails with an opaque "tsc: not found" and the user has to guess. This adds a best-effort local sanity check to `apify push` that inspects the Dockerfile and package.json before upload and emits a yellow warning (not an error) when all three conditions hold: (1) `.actor/Dockerfile` or `./Dockerfile` contains a dev-dep-dropping `npm ci|install|i --omit=dev` (or `--production`), (2) `scripts.build` shells out to a standalone `tsc` (not tsc-alias, tsdown, etc.), and (3) `typescript` is in devDependencies but not in dependencies. The warning points to the two standard fixes: multi-stage Dockerfile with a build stage that keeps devDeps, or drop `--omit=dev`. Push is never blocked — the platform build remains the source of truth. Companion to apify/apify-docs #2716 (docs side of the same finding). Co-Authored-By: Claude Opus 4.7 --- src/commands/actors/push.ts | 74 +++++++++++++++++++- test/local/commands/push.test.ts | 114 ++++++++++++++++++++++++++++++- 2 files changed, 186 insertions(+), 2 deletions(-) diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index 683462834..f0fdbc66d 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -1,4 +1,4 @@ -import { readFileSync, statSync, unlinkSync } from 'node:fs'; +import { existsSync, readFileSync, statSync, unlinkSync } from 'node:fs'; import { join, resolve } from 'node:path'; import process from 'node:process'; @@ -121,6 +121,70 @@ export function resolvePushOutcome(buildStatus: string): PushOutcome { } } +// Best-effort pre-upload check for a common TypeScript packaging trap: a +// single-stage Dockerfile that runs `npm install --omit=dev` (or +// `--production`) and a package.json build script that shells out to `tsc`, +// while `typescript` sits in devDependencies. The platform-side Docker build +// will fail because tsc is dropped before `npm run build` runs. This is a +// warning only — the source of truth is the platform build. +export function detectOmitDevTscTrap(cwd: string): string | null { + const dockerfileCandidates = [join(cwd, '.actor', 'Dockerfile'), join(cwd, 'Dockerfile')]; + let dockerfileContent: string | null = null; + for (const candidate of dockerfileCandidates) { + if (existsSync(candidate)) { + try { + dockerfileContent = readFileSync(candidate, 'utf8'); + break; + } catch { + // unreadable — ignore, this check is best-effort + } + } + } + if (!dockerfileContent) return null; + + // Match `npm ci|install|i` followed anywhere on the same command by + // `--omit=dev` or `--production` (with optional `=true`). Line-based to + // avoid crossing RUN boundaries. Backslash continuations are handled by + // checking each logical `\` -joined chunk. + const commandChunks = dockerfileContent + .replace(/\\\n/g, ' ') // fold backslash-continuations onto one line + .split('\n'); + const dropsDevDeps = commandChunks.some( + (line) => /\bnpm\s+(ci|install|i)\b/.test(line) && /(--omit[= ]dev|--production(?:[= ]true)?)/.test(line), + ); + if (!dropsDevDeps) return null; + + // package.json lookup + const packageJsonPath = join(cwd, 'package.json'); + if (!existsSync(packageJsonPath)) return null; + let pkg: { + scripts?: Record; + dependencies?: Record; + devDependencies?: Record; + }; + try { + pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8')); + } catch { + // malformed package.json — not our problem to diagnose here + return null; + } + + const buildScript = pkg.scripts?.build ?? ''; + // Match a standalone `tsc` invocation (not `tsc-alias`, `tsconfig`, etc.). + const buildUsesTsc = /(^|[\s&|;])tsc($|[\s&|;])/.test(buildScript); + if (!buildUsesTsc) return null; + + const tsInDev = !!pkg.devDependencies?.typescript; + const tsInProd = !!pkg.dependencies?.typescript; + if (!tsInDev || tsInProd) return null; + + return [ + 'npm --omit=dev drops typescript (in devDependencies); the build stage needs tsc.', + 'Either (a) use a multi-stage Dockerfile where the build stage installs devDeps then', + 'copies dist/ to the runtime stage, or (b) drop --omit=dev.', + ].join('\n '); +} + export class ActorsPushCommand extends ApifyCommand { static override name = 'push' as const; @@ -326,6 +390,14 @@ export class ActorsPushCommand extends ApifyCommand { info({ message: `Deploying Actor '${actorConfig!.name}' to Apify.` }); + // Best-effort local sanity check: warn (never block) if the Dockerfile + // drops devDependencies before running a tsc-based build script. See + // detectOmitDevTscTrap for the full heuristic. + const trapWarning = detectOmitDevTscTrap(cwd); + if (trapWarning) { + warning({ message: trapWarning }); + } + const filesSize = await sumFilesSizeInBytes(filePathsToPush, cwd); let sourceType; diff --git a/test/local/commands/push.test.ts b/test/local/commands/push.test.ts index 77c9ed7c6..9288282f3 100644 --- a/test/local/commands/push.test.ts +++ b/test/local/commands/push.test.ts @@ -1,6 +1,10 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { ACTOR_JOB_STATUSES } from '@apify/consts'; -import { resolvePushOutcome } from '../../../src/commands/actors/push.js'; +import { detectOmitDevTscTrap, resolvePushOutcome } from '../../../src/commands/actors/push.js'; import { CommandExitCodes } from '../../../src/lib/consts.js'; describe('resolvePushOutcome', () => { @@ -37,3 +41,111 @@ describe('resolvePushOutcome', () => { expect(resolvePushOutcome(ACTOR_JOB_STATUSES.FAILED).errorMessage).toBe('Build failed'); }); }); + +describe('detectOmitDevTscTrap', () => { + function makeProject(files: Record): string { + const root = mkdtempSync(join(tmpdir(), 'apify-cli-push-trap-')); + for (const [rel, content] of Object.entries(files)) { + const abs = join(root, rel); + mkdirSync(join(abs, '..'), { recursive: true }); + writeFileSync(abs, content); + } + return root; + } + + test('warns when Dockerfile omits dev deps and build uses tsc with typescript only in devDependencies', () => { + const root = makeProject({ + Dockerfile: 'FROM node:20\nRUN npm install --omit=dev\nCMD ["node", "dist/main.js"]\n', + 'package.json': JSON.stringify({ + scripts: { build: 'tsc' }, + devDependencies: { typescript: '^5.0.0' }, + }), + }); + expect(detectOmitDevTscTrap(root)).toMatch(/npm --omit=dev/); + }); + + test('warns for .actor/Dockerfile as well', () => { + const root = makeProject({ + '.actor/Dockerfile': 'FROM node:20\nRUN npm ci --omit=dev\n', + 'package.json': JSON.stringify({ + scripts: { build: 'tsc -p tsconfig.build.json' }, + devDependencies: { typescript: '^5.0.0' }, + }), + }); + expect(detectOmitDevTscTrap(root)).not.toBeNull(); + }); + + test('also recognizes --production as dev-drop flag', () => { + const root = makeProject({ + Dockerfile: 'FROM node:20\nRUN npm install --production\n', + 'package.json': JSON.stringify({ + scripts: { build: 'tsc' }, + devDependencies: { typescript: '^5.0.0' }, + }), + }); + expect(detectOmitDevTscTrap(root)).not.toBeNull(); + }); + + test('does not warn when typescript is also in dependencies (already available at build time)', () => { + const root = makeProject({ + Dockerfile: 'FROM node:20\nRUN npm install --omit=dev\n', + 'package.json': JSON.stringify({ + scripts: { build: 'tsc' }, + dependencies: { typescript: '^5.0.0' }, + devDependencies: { typescript: '^5.0.0' }, + }), + }); + expect(detectOmitDevTscTrap(root)).toBeNull(); + }); + + test('does not warn when Dockerfile does not drop dev deps', () => { + const root = makeProject({ + Dockerfile: 'FROM node:20\nRUN npm install\n', + 'package.json': JSON.stringify({ + scripts: { build: 'tsc' }, + devDependencies: { typescript: '^5.0.0' }, + }), + }); + expect(detectOmitDevTscTrap(root)).toBeNull(); + }); + + test('does not warn when build script does not use tsc', () => { + const root = makeProject({ + Dockerfile: 'FROM node:20\nRUN npm install --omit=dev\n', + 'package.json': JSON.stringify({ + scripts: { build: 'tsdown' }, + devDependencies: { typescript: '^5.0.0' }, + }), + }); + expect(detectOmitDevTscTrap(root)).toBeNull(); + }); + + test('does not confuse tsc-alias / tsconfig-paths for a tsc invocation', () => { + const root = makeProject({ + Dockerfile: 'FROM node:20\nRUN npm install --omit=dev\n', + 'package.json': JSON.stringify({ + scripts: { build: 'tsc-alias' }, + devDependencies: { typescript: '^5.0.0' }, + }), + }); + expect(detectOmitDevTscTrap(root)).toBeNull(); + }); + + test('returns null when there is no Dockerfile', () => { + const root = makeProject({ + 'package.json': JSON.stringify({ + scripts: { build: 'tsc' }, + devDependencies: { typescript: '^5.0.0' }, + }), + }); + expect(detectOmitDevTscTrap(root)).toBeNull(); + }); + + test('returns null when package.json is missing or malformed', () => { + const root = makeProject({ + Dockerfile: 'FROM node:20\nRUN npm install --omit=dev\n', + 'package.json': '{ this is not valid json', + }); + expect(detectOmitDevTscTrap(root)).toBeNull(); + }); +});