Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,6 @@
"jwt-decode": "4.0.0",
"lambda-local": "2.2.0",
"locate-path": "8.0.0",
"lodash": "4.18.1",
"log-update": "7.2.0",
"maxstache": "1.0.7",
"maxstache-stream": "1.0.4",
Expand Down Expand Up @@ -176,7 +175,6 @@
"@types/inquirer": "9.0.9",
"@types/inquirer-autocomplete-prompt": "3.0.3",
"@types/jsonwebtoken": "9.0.10",
"@types/lodash": "4.17.24",
"@types/lodash.shuffle": "4.2.9",
"@types/multiparty": "4.2.1",
"@types/node": "22.18.11",
Expand Down
5 changes: 2 additions & 3 deletions src/commands/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ import debug from 'debug'
import { findUp } from 'find-up'
import inquirer from 'inquirer'
import inquirerAutocompletePrompt from 'inquirer-autocomplete-prompt'
import merge from 'lodash/merge.js'
import pick from 'lodash/pick.js'
import { deepMerge, pick } from '../utils/object-utilities.js'

import { getAgent } from '../lib/http-agent.js'
import {
Expand Down Expand Up @@ -191,7 +190,7 @@ export function storeToken(
globalConfig: Awaited<ReturnType<typeof getGlobalConfigStore>>,
{ userId, name, email, accessToken }: { userId: string; name?: string; email?: string; accessToken: string },
) {
const userData = merge(globalConfig.get(`users.${userId}`), {
const userData = deepMerge(globalConfig.get(`users.${userId}`), {
id: userId,
name,
email,
Expand Down
9 changes: 6 additions & 3 deletions src/commands/deploy/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ import { stdin, stdout } from 'process'
import type { NetlifyAPI } from '@netlify/api'
import { type NetlifyConfig, type OnPostBuild, runCoreSteps } from '@netlify/build'
import inquirer from 'inquirer'
import isEmpty from 'lodash/isEmpty.js'
import isObject from 'lodash/isObject.js'
import { parseAllHeaders } from '@netlify/headers-parser'
import { parseAllRedirects } from '@netlify/redirect-parser'
import prettyjson from 'prettyjson'
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand Down Expand Up @@ -45,6 +43,7 @@ import { type DeployEvent, deploySite } from '../../utils/deploy/deploy-site.js'
import { uploadSourceZip } from '../../utils/deploy/upload-source-zip.js'
import { getEnvelopeEnv } from '../../utils/env/index.js'
import { getFunctionsManifestPath, getInternalFunctionsDir } from '../../utils/functions/index.js'
import { isEmpty } from '../../utils/object-utilities.js'
import openBrowser from '../../utils/open-browser.js'
import { isInteractive } from '../../utils/scripted-commands.js'
import { resolveTeamForNonInteractive } from '../../utils/team.js'
Expand Down Expand Up @@ -359,7 +358,11 @@ const generateDeployCommand = (

// @ts-expect-error TS(7031) FIXME: Binding element 'api' implicitly has an 'any' type... Remove this comment to see the full error message
const prepareProductionDeploy = async ({ api, siteData, options, command }) => {
if (isObject(siteData.published_deploy) && siteData.published_deploy.locked) {
if (
typeof siteData.published_deploy === 'object' &&
siteData.published_deploy !== null &&
siteData.published_deploy.locked
) {
log(`\n${NETLIFYDEVERR} Deployments are "locked" for production context of this project\n`)

const overrideCommand = generateDeployCommand({ ...options, prodIfUnlocked: true, prod: false }, [], command)
Expand Down
2 changes: 1 addition & 1 deletion src/commands/init/init.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { OptionValues } from 'commander'
import inquirer from 'inquirer'
import isEmpty from 'lodash/isEmpty.js'
import { isEmpty } from '../../utils/object-utilities.js'

import { chalk, exit, log, netlifyCommand } from '../../utils/command-helpers.js'
import getRepoData from '../../utils/get-repo-data.js'
Expand Down
2 changes: 1 addition & 1 deletion src/commands/link/link.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import assert from 'node:assert'

import inquirer from 'inquirer'
import isEmpty from 'lodash/isEmpty.js'
import { isEmpty } from '../../utils/object-utilities.js'
import type { NetlifyAPI } from '@netlify/api'

import { listSites } from '../../lib/api.js'
Expand Down
2 changes: 1 addition & 1 deletion src/commands/sites/sites-create.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { OptionValues } from 'commander'
import inquirer from 'inquirer'
import pick from 'lodash/pick.js'
import { pick } from '../../utils/object-utilities.js'
import prettyjson from 'prettyjson'

import { chalk, logAndThrowError, log, logJson, warn, type APIError } from '../../utils/command-helpers.js'
Expand Down
2 changes: 1 addition & 1 deletion src/lib/extensions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Project } from '@netlify/build-info'
import isEmpty from 'lodash/isEmpty.js'
import { isEmpty } from '../utils/object-utilities.js'
import type { NetlifySite } from '../commands/types.js'
import type { SiteInfo } from '../utils/types.js'

Expand Down
2 changes: 1 addition & 1 deletion src/utils/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import process from 'process'

import type { NetlifyAPI } from '@netlify/api'
import getPort from 'get-port'
import isEmpty from 'lodash/isEmpty.js'
import { isEmpty } from './object-utilities.js'

import { supportsBackgroundFunctions } from '../lib/account.js'

Expand Down
102 changes: 102 additions & 0 deletions src/utils/object-utilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* Checks whether a value is effectively empty.
*
* A value is considered empty when it is `null`, `undefined`, or an object
* with no own enumerable keys. Intended as a minimal stand-in for the handful
* of `lodash/isEmpty` call sites that only ever receive plain objects or
* nullish values (e.g. API response payloads that may be absent).
*
* @param obj - The object to check, or a nullish value.
* @returns `true` if the value is nullish or has no own enumerable keys.
*/
export const isEmpty = (obj: object | null | undefined): boolean => obj == null || Object.keys(obj).length === 0

/**
* Returns a new object containing only the specified keys from the source.
*
* Mirrors the shape of `lodash/pick` but is intentionally loosely typed so it
* can accept string keys that are not declared on the source type. This is
* pragmatic for CLI output filtering of API responses whose generated types
* sometimes omit fields the server actually returns.
*
* Keys that are not present on the source are silently skipped.
*
* @param obj - The source object to pick properties from.
* @param keys - The property names to include in the result.
* @returns A new object with only the requested keys that exist on the source.
*/
export const pick = <T extends object>(obj: T, keys: readonly string[]): Partial<T> =>
Object.fromEntries(keys.filter((k) => k in obj).map((k) => [k, (obj as Record<string, unknown>)[k]])) as Partial<T>

/**
* Recursively merges the properties of `source` into a copy of `target`.
*
* Behavior matches `lodash/merge` in the ways the existing call sites rely on:
* - `undefined` source values are skipped, preserving any existing value in
* the target. This lets callers pass partial patch objects without having
* to strip keys that happen to be `undefined` (e.g. `storeToken` in
* `base-command.ts` passes `{ github: { user: undefined, token: undefined } }`
* and relies on the existing GitHub auth fields being preserved).
* - `null` source values DO overwrite -- only `undefined` is treated as
* "no-op".
* - Only plain (non-array) objects are merged recursively; arrays and
* primitive values from `source` overwrite the corresponding value in
* `target`.
*
* Neither argument is mutated.
*
* @param target - The base object to merge into. `undefined` is treated as `{}`.
* @param source - The object whose properties take precedence over `target`.
* @returns A new object containing the combined properties.
*/
export function deepMerge<T extends Record<string, unknown>>(
target: T | undefined,
source: Record<string, unknown>,
): T {
const result: Record<string, unknown> = { ...(target ?? {}) }
for (const key of Object.keys(source)) {
const sourceVal = source[key]
if (sourceVal === undefined) continue
const targetVal = result[key]
if (
targetVal != null &&
sourceVal != null &&
typeof targetVal === 'object' &&
typeof sourceVal === 'object' &&
!Array.isArray(targetVal) &&
!Array.isArray(sourceVal)
) {
result[key] = deepMerge(targetVal as Record<string, unknown>, sourceVal as Record<string, unknown>)
} else {
result[key] = sourceVal
}
}
return result as T
}

/**
* Wraps a function so that it runs at most once per `intervalMs` milliseconds.
*
* Uses leading-edge invocation: the first call runs immediately, and any calls
* that arrive within `intervalMs` of the previous invocation are dropped.
* There is no trailing call -- this is intentional for fire-and-forget
* side-effect callers (e.g. rate-limited activity pings) where losing the
* final invocation is acceptable.
*
* @param fn - The function to throttle.
* @param intervalMs - Minimum time between invocations, in milliseconds.
* @returns A throttled wrapper around `fn` with the same arguments signature.
*/
export function throttle<Args extends unknown[]>(
fn: (...args: Args) => void,
intervalMs: number,
): (...args: Args) => void {
let lastCall = 0
return (...args: Args) => {
const now = Date.now()
if (now - lastCall >= intervalMs) {
lastCall = now
fn(...args)
}
}
}
Comment on lines +1 to +102
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Prettier --check is failing on this file.

The CI Format job reports formatting issues. Please run npm run format (or equivalent) before pushing so prettier --check passes.

🧰 Tools
🪛 GitHub Actions: Format

[warning] 1-1: Prettier --check reported formatting/style issues in this file.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/object-utilities.ts` around lines 1 - 90, Prettier check failed for
this file; run the project formatter and commit the changes so formatting
matches CI. Run the repo's format script (e.g. npm run format or prettier
--write) on src/utils/object-utilities.ts, then review and stage the updated
file (covering exports isEmpty, pick, deepMerge, throttle) and push the
formatted version so prettier --check passes in CI.

2 changes: 1 addition & 1 deletion src/utils/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import httpProxy from 'http-proxy'
import { createProxyMiddleware } from 'http-proxy-middleware'
import { jwtDecode } from 'jwt-decode'
import { locatePath } from 'locate-path'
import throttle from 'lodash/throttle.js'
import { throttle } from './object-utilities.js'
import type { Match } from 'netlify-redirector'
import pFilter from 'p-filter'

Expand Down
Loading
Loading