Merge branch 'backstage:master' into integrations/gitlab-url-reader-token

This commit is contained in:
Captain Fizzbin
2025-04-24 08:04:42 -04:00
committed by GitHub
20 changed files with 560 additions and 26 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-kubernetes-react': patch
---
Add headlamp formatter
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-kubernetes-backend': patch
---
Changed logging of cluster details to debug to minimise log clutter.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-components': patch
---
Fix the hidden sidebar's sub-menu when the sidebar is scrollable
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-org': patch
---
Fixed missing spec.profile field on MyGroupsSidebarItem.tsx so the group spec.profile.displayName is shown on the sidebar"
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-notifications-backend-module-slack': patch
---
Added email-based Slack User ID lookup if `metadata.annotations.slack.com/bot-notify` is missing from user entity
+1 -1
View File
@@ -67,6 +67,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: 'Upload to code-scanning'
uses: github/codeql-action/upload-sarif@45775bd8235c68ba998cffa5171334d58593da47 # v3.28.15
uses: github/codeql-action/upload-sarif@28deaeda66b76a05916b6923827895f2b14ab387 # v3.28.16
with:
sarif_file: results.sarif
+1 -1
View File
@@ -58,6 +58,6 @@ jobs:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
NODE_OPTIONS: --max-old-space-size=7168
- name: Upload Snyk report
uses: github/codeql-action/upload-sarif@45775bd8235c68ba998cffa5171334d58593da47 # v3.28.15
uses: github/codeql-action/upload-sarif@28deaeda66b76a05916b6923827895f2b14ab387 # v3.28.16
with:
sarif_file: snyk.sarif
+3 -3
View File
@@ -55,7 +55,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@45775bd8235c68ba998cffa5171334d58593da47 # v3.28.15
uses: github/codeql-action/init@28deaeda66b76a05916b6923827895f2b14ab387 # v3.28.16
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -66,7 +66,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@45775bd8235c68ba998cffa5171334d58593da47 # v3.28.15
uses: github/codeql-action/autobuild@28deaeda66b76a05916b6923827895f2b14ab387 # v3.28.16
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -80,4 +80,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@45775bd8235c68ba998cffa5171334d58593da47 # v3.28.15
uses: github/codeql-action/analyze@28deaeda66b76a05916b6923827895f2b14ab387 # v3.28.16
+36 -2
View File
@@ -355,8 +355,7 @@ Specifies the app that provides the Kubernetes dashboard.
This will be used for formatting links to kubernetes objects inside the
dashboard.
The supported dashboards are: `standard`, `rancher`, `openshift`, `gke`, `aks`,
`eks`. However, not all of them are implemented yet, so please contribute!
The supported dashboards are: `aks`, `eks`, `gke`, `headlamp`, `openshift`, `rancher`, `standard`. However, not all of them are implemented yet, so please contribute!
Note that it will default to the regular dashboard provided by the Kubernetes
project (`standard`), that can run in any Kubernetes cluster.
@@ -448,6 +447,41 @@ cluster locator method can be configured in this way.
Configures which [custom resources][3] to look for when returning an entity's
Kubernetes resources belonging to the cluster. Same specification as [`customResources`](#customresources-optional)
#### `headlamp`
When using `headlamp` as your dashboard, you have two configuration options:
1. External Headlamp instance:
```yaml
kubernetes:
clusterLocatorMethods:
- type: 'config'
clusters:
- url: http://127.0.0.1:9999
name: my-cluster
dashboardUrl: http://headlamp.example.com # Your Headlamp instance URL
dashboardApp: 'headlamp'
dashboardParameters:
clusterName: 'my-cluster' # Optional, defaults to 'default'
```
2. Internal Headlamp (When using the Headlamp plugin for Backstage):
```yaml
kubernetes:
clusterLocatorMethods:
- type: 'config'
clusters:
- url: http://127.0.0.1:9999
name: my-cluster
dashboardApp: 'headlamp'
dashboardParameters:
internal: true
headlampRoute: '/headlamp' # Optional, defaults to '/headlamp'
clusterName: 'my-cluster' # Optional, defaults to 'default'
```
#### `gke`
This cluster locator is designed to work with Kubernetes clusters running in
@@ -41,15 +41,21 @@ import { coreComponentsTranslationRef } from '../../translation';
export type SidebarClassKey = 'drawer' | 'drawerOpen';
const useStyles = makeStyles<Theme, { sidebarConfig: SidebarConfig }>(
theme => ({
drawer: {
display: 'flex',
flexFlow: 'column nowrap',
alignItems: 'flex-start',
position: 'fixed',
root: {
left: 0,
top: 0,
bottom: 0,
zIndex: theme.zIndex.appBar,
position: 'fixed',
},
drawer: {
display: 'flex',
flexFlow: 'column nowrap',
alignItems: 'flex-start',
left: 0,
top: 0,
bottom: 0,
position: 'absolute',
background: theme.palette.navigation.background,
overflowX: 'hidden',
msOverflowStyle: 'none',
@@ -488,7 +488,7 @@ export class KubernetesBuilder {
) {
const clusterDetails = await clusterSupplier.getClusters(options);
this.env.logger.info(
this.env.logger.debug(
`action=loadClusterDetails numOfClustersLoaded=${clusterDetails.length}`,
);
+6
View File
@@ -283,6 +283,12 @@ export class GoogleKubernetesAuthProvider implements KubernetesAuthProvider {
// @public (undocumented)
export const GroupedResponsesContext: Context<GroupedResponses>;
// @public (undocumented)
export class HeadlampClusterLinksFormatter implements ClusterLinksFormatter {
// (undocumented)
formatClusterLink(options: ClusterLinksFormatterOptions): Promise<URL>;
}
// @public (undocumented)
export const HorizontalPodAutoscalerDrawer: (props: {
hpa: V2HorizontalPodAutoscaler;
@@ -0,0 +1,83 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { HeadlampClusterLinksFormatter } from './HeadlampClusterLinksFormatter';
import { ClusterLinksFormatterOptions } from '../../types';
describe('HeadlampClusterLinksFormatter', () => {
let formatter: HeadlampClusterLinksFormatter;
beforeEach(() => {
formatter = new HeadlampClusterLinksFormatter();
// Mock window.location.origin
Object.defineProperty(window, 'location', {
value: { origin: 'http://localhost:3000' },
writable: true,
});
});
it('formats internal dashboard link correctly', async () => {
const options: ClusterLinksFormatterOptions = {
dashboardParameters: {
internal: true,
headlampRoute: '/headlamp',
clusterName: 'test-cluster',
},
object: { metadata: { name: 'test-pod', namespace: 'default' } },
kind: 'Pod',
};
const result = await formatter.formatClusterLink(options);
expect(result.toString()).toBe(
'http://localhost:3000/headlamp?to=%2Fc%2Ftest-cluster%2Fpods%2Fdefault%2Ftest-pod',
);
});
it('formats external dashboard link correctly', async () => {
const options: ClusterLinksFormatterOptions = {
dashboardUrl: new URL('https://headlamp.com'),
object: { metadata: { name: 'test-deployment', namespace: 'default' } },
kind: 'Deployment',
};
const result = await formatter.formatClusterLink(options);
expect(result.toString()).toBe(
'https://headlamp.com/?to=%2Fc%2Fdefault%2Fdeployments%2Fdefault%2Ftest-deployment',
);
});
it('throws error when dashboard URL is missing for external dashboard', async () => {
const options: ClusterLinksFormatterOptions = {
object: { metadata: { name: 'test-service', namespace: 'default' } },
kind: 'Service',
};
await expect(formatter.formatClusterLink(options)).rejects.toThrow(
'Dashboard URL is required or dashboardInternal must be true',
);
});
it('throws error for unsupported kind', async () => {
const options: ClusterLinksFormatterOptions = {
dashboardParameters: { internal: true },
object: { metadata: { name: 'test-unknown', namespace: 'default' } },
kind: 'UnknownKind',
};
await expect(formatter.formatClusterLink(options)).rejects.toThrow(
'Could not find path for kind: UnknownKind',
);
});
});
@@ -0,0 +1,136 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
ClusterLinksFormatter,
ClusterLinksFormatterOptions,
} from '../../types';
/** @public */
export class HeadlampClusterLinksFormatter implements ClusterLinksFormatter {
async formatClusterLink(options: ClusterLinksFormatterOptions): Promise<URL> {
const { dashboardUrl, dashboardParameters, object, kind } = options;
if (!dashboardUrl && !dashboardParameters?.internal) {
throw new Error(
'Dashboard URL is required or dashboardInternal must be true',
);
}
const clusterName =
(dashboardParameters?.clusterName as string) ?? 'default';
const path = this.getHeadlampPath(kind, object, clusterName);
if (!path) {
throw new Error(`Could not find path for kind: ${kind}`);
}
let baseUrl: URL;
if (dashboardParameters?.internal) {
baseUrl = new URL(window.location.origin);
baseUrl.pathname =
(dashboardParameters.headlampRoute as string) || '/headlamp';
} else {
if (!dashboardUrl?.href) {
throw new Error(
'Dashboard URL is required when not using internal dashboard',
);
}
baseUrl = new URL(dashboardUrl.href);
}
baseUrl.searchParams.set('to', path);
return baseUrl;
}
private readonly NAMESPACED_RESOURCES = new Set([
'pod',
'deployment',
'replicaset',
'statefulset',
'daemonset',
'job',
'cronjob',
'service',
'ingress',
'configmap',
'secret',
'serviceaccount',
'role',
'rolebinding',
'networkpolicy',
'horizontalpodautoscaler',
'poddisruptionbudget',
'persistentvolumeclaim',
]);
private getHeadlampPath(
kind: string,
object: {
metadata?: {
name?: string;
namespace?: string;
};
},
clusterName: string,
): string {
const lowercaseKind = kind.toLocaleLowerCase('en-US');
const { name } = object.metadata ?? {};
let { namespace } = object.metadata ?? {};
if (!name) {
throw new Error(`Resource name is required for kind: ${kind}`);
}
// Add namespace validation
if (this.NAMESPACED_RESOURCES.has(lowercaseKind) && !namespace) {
throw new Error(`Namespace is required for namespaced resource: ${kind}`);
}
if (!namespace) {
namespace = 'default';
}
const pathMap: Record<string, string> = {
namespace: `/c/${clusterName}/namespaces/${name}`,
node: `/c/${clusterName}/nodes/${name}`,
persistentvolume: `/c/${clusterName}/storage/persistentvolumes/${name}`,
persistentvolumeclaim: `/c/${clusterName}/storage/persistentvolumeclaims/${namespace}/${name}`,
pod: `/c/${clusterName}/pods/${namespace}/${name}`,
deployment: `/c/${clusterName}/deployments/${namespace}/${name}`,
replicaset: `/c/${clusterName}/replicasets/${namespace}/${name}`,
statefulset: `/c/${clusterName}/statefulsets/${namespace}/${name}`,
daemonset: `/c/${clusterName}/daemonsets/${namespace}/${name}`,
job: `/c/${clusterName}/jobs/${namespace}/${name}`,
cronjob: `/c/${clusterName}/cronjobs/${namespace}/${name}`,
service: `/c/${clusterName}/services/${namespace}/${name}`,
ingress: `/c/${clusterName}/ingresses/${namespace}/${name}`,
configmap: `/c/${clusterName}/configmaps/${namespace}/${name}`,
secret: `/c/${clusterName}/secrets/${namespace}/${name}`,
serviceaccount: `/c/${clusterName}/serviceaccounts/${namespace}/${name}`,
role: `/c/${clusterName}/roles/${namespace}/${name}`,
rolebinding: `/c/${clusterName}/rolebindings/${namespace}/${name}`,
clusterrole: `/c/${clusterName}/clusterroles/${name}`,
clusterrolebinding: `/c/${clusterName}/clusterrolebindings/${name}`,
storageclass: `/c/${clusterName}/storage/storageclasses/${name}`,
networkpolicy: `/c/${clusterName}/networkpolicies/${namespace}/${name}`,
horizontalpodautoscaler: `/c/${clusterName}/horizontalpodautoscalers/${namespace}/${name}`,
poddisruptionbudget: `/c/${clusterName}/poddisruptionbudgets/${namespace}/${name}`,
customresourcedefinition: `/c/${clusterName}/customresourcedefinitions/${name}`,
};
return pathMap[lowercaseKind] ?? '';
}
}
@@ -21,6 +21,7 @@ import { GkeClusterLinksFormatter } from './GkeClusterLinksFormatter';
import { StandardClusterLinksFormatter } from './StandardClusterLinksFormatter';
import { OpenshiftClusterLinksFormatter } from './OpenshiftClusterLinksFormatter';
import { RancherClusterLinksFormatter } from './RancherClusterLinksFormatter';
import { HeadlampClusterLinksFormatter } from './HeadlampClusterLinksFormatter';
import { ProfileInfoApi } from '@backstage/core-plugin-api';
export {
@@ -30,6 +31,7 @@ export {
GkeClusterLinksFormatter,
OpenshiftClusterLinksFormatter,
RancherClusterLinksFormatter,
HeadlampClusterLinksFormatter,
};
/** @public */
@@ -46,5 +48,6 @@ export function getDefaultFormatters(deps: {
gke: new GkeClusterLinksFormatter(deps.googleAuthApi),
openshift: new OpenshiftClusterLinksFormatter(),
rancher: new RancherClusterLinksFormatter(),
headlamp: new HeadlampClusterLinksFormatter(),
};
}
@@ -50,6 +50,7 @@ jest.mock('@slack/web-api', () => {
},
],
})),
lookupByEmail: jest.fn(),
},
};
return { WebClient: jest.fn(() => mockSlack) };
@@ -81,6 +82,27 @@ const DEFAULT_ENTITIES_RESPONSE = {
},
},
} as unknown as Entity,
{
kind: 'User',
metadata: {
name: 'mock-without-slack-annotation',
namespace: 'default',
annotations: {},
},
spec: {
profile: {
email: 'test@example.com',
},
},
} as unknown as Entity,
{
kind: 'Group',
metadata: {
name: 'mock-without-slack-annotation',
namespace: 'default',
annotations: {},
},
} as unknown as Entity,
],
};
@@ -368,4 +390,157 @@ describe('SlackNotificationProcessor', () => {
expect(slack.chat.postMessage).toHaveBeenCalledTimes(2);
});
});
describe('when slack.com/bot-notify annotation is missing', () => {
it('should not send notification to a group without annotation', async () => {
const slack = new WebClient();
const processor = SlackNotificationProcessor.fromConfig(config, {
auth,
discovery,
logger,
catalog: catalogServiceMock({
entities: DEFAULT_ENTITIES_RESPONSE.items,
}),
slack,
})[0];
await processor.processOptions({
recipients: {
type: 'entity',
entityRef: 'group:default/mock-without-slack-annotation',
},
payload: { title: 'notification' },
});
expect(slack.chat.postMessage).not.toHaveBeenCalled();
});
it('should not send notification to a user without annotation and email', async () => {
const slack = new WebClient();
const processor = SlackNotificationProcessor.fromConfig(config, {
auth,
discovery,
logger,
catalog: catalogServiceMock({
entities: [DEFAULT_ENTITIES_RESPONSE.items[2]],
}),
slack,
})[0];
await processor.postProcess(
{
origin: 'plugin',
id: '1234',
user: 'user:default/mock-without-slack-annotation',
created: new Date(),
payload: {
title: 'notification',
link: '/catalog/user/default/jane.doe',
},
},
{
recipients: {
type: 'entity',
entityRef: 'user:default/mock-without-slack-annotation',
},
payload: { title: 'notification' },
},
);
expect(slack.chat.postMessage).not.toHaveBeenCalled();
});
it('should try to find user by email when annotation is missing', async () => {
const slack = new WebClient();
(slack.users.lookupByEmail as jest.Mock).mockResolvedValueOnce({
ok: true,
user: { id: 'U12345678' },
});
const processor = SlackNotificationProcessor.fromConfig(config, {
auth,
discovery,
logger,
catalog: catalogServiceMock({
entities: DEFAULT_ENTITIES_RESPONSE.items,
}),
slack,
})[0];
await processor.postProcess(
{
origin: 'plugin',
id: '1234',
user: 'user:default/mock-without-slack-annotation',
created: new Date(),
payload: {
title: 'notification',
link: '/catalog/user/default/jane.doe',
},
},
{
recipients: {
type: 'entity',
entityRef: 'user:default/mock-without-slack-annotation',
},
payload: { title: 'notification' },
},
);
expect(slack.users.lookupByEmail).toHaveBeenCalledWith({
email: 'test@example.com',
});
expect(slack.chat.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
channel: 'U12345678',
}),
);
});
it('should log warning when email lookup fails', async () => {
const slack = new WebClient();
(slack.users.lookupByEmail as jest.Mock).mockRejectedValueOnce(
new Error('User not found'),
);
const processor = SlackNotificationProcessor.fromConfig(config, {
auth,
discovery,
logger,
catalog: catalogServiceMock({
entities: DEFAULT_ENTITIES_RESPONSE.items,
}),
slack,
})[0];
await processor.postProcess(
{
origin: 'plugin',
id: '1234',
user: 'user:default/mock-without-slack-annotation',
created: new Date(),
payload: {
title: 'notification',
link: '/catalog/user/default/jane.doe',
},
},
{
recipients: {
type: 'entity',
entityRef: 'user:default/mock-without-slack-annotation',
},
payload: { title: 'notification' },
},
);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining(
'Failed to lookup Slack user by email test@example.com',
),
);
expect(slack.chat.postMessage).not.toHaveBeenCalled();
});
});
});
@@ -20,7 +20,12 @@ import {
LoggerService,
} from '@backstage/backend-plugin-api';
import { CatalogApi } from '@backstage/catalog-client';
import { Entity, parseEntityRef } from '@backstage/catalog-model';
import {
Entity,
isUserEntity,
parseEntityRef,
UserEntity,
} from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { NotFoundError } from '@backstage/errors';
import { Notification } from '@backstage/plugin-notifications-common';
@@ -255,7 +260,11 @@ export class SlackNotificationProcessor implements NotificationProcessor {
const response = await this.catalog.getEntitiesByRefs(
{
entityRefs: entityRefs.slice(),
fields: [`metadata.annotations.${ANNOTATION_SLACK_BOT_NOTIFY}`],
fields: [
`kind`,
`spec.profile.email`,
`metadata.annotations.${ANNOTATION_SLACK_BOT_NOTIFY}`,
],
},
{
token,
@@ -278,7 +287,42 @@ export class SlackNotificationProcessor implements NotificationProcessor {
throw new NotFoundError(`Entity not found: ${entityRef}`);
}
return entity?.metadata?.annotations?.[ANNOTATION_SLACK_BOT_NOTIFY];
const slackId = await this.resolveSlackId(entity);
return slackId;
}
private async resolveSlackId(entity: Entity): Promise<string | undefined> {
// First try to get Slack ID from annotations
const slackId = entity.metadata?.annotations?.[ANNOTATION_SLACK_BOT_NOTIFY];
if (slackId) {
return slackId;
}
// If no Slack ID in annotations and entity is a User, try to find by email
if (isUserEntity(entity)) {
return this.findSlackIdByEmail(entity);
}
return undefined;
}
private async findSlackIdByEmail(
entity: UserEntity,
): Promise<string | undefined> {
const email = entity.spec?.profile?.email;
if (!email) {
return undefined;
}
try {
const user = await this.slack.users.lookupByEmail({ email });
return user.user?.id;
} catch (error) {
this.logger.warn(
`Failed to lookup Slack user by email ${email}: ${error}`,
);
return undefined;
}
}
async sendNotification(args: ChatPostMessageArguments): Promise<void> {
@@ -32,6 +32,7 @@ import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils';
import { DefaultEntityPresentationApi } from '@backstage/plugin-catalog';
import userEvent from '@testing-library/user-event';
import { screen } from '@testing-library/react';
import { GetEntitiesRequest } from '@backstage/catalog-client';
describe('MyGroupsSidebarItem Test', () => {
describe('For guests or users with no groups', () => {
@@ -130,8 +131,9 @@ describe('MyGroupsSidebarItem Test', () => {
ownershipEntityRefs: ['user:default/nigel.manning'],
});
const catalogApi = catalogApiMock.mock({
getEntities: async () => ({
items: [
getEntities: async (request: GetEntitiesRequest = {}) => {
const { fields } = request;
const fullItems = [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Group',
@@ -172,8 +174,28 @@ describe('MyGroupsSidebarItem Test', () => {
},
},
},
] as Entity[],
}),
] as Entity[];
// Filter requested fields
const filteredItems = fullItems.map(item => {
const filtered: Record<string, any> = { apiVersion: item.apiVersion };
(fields || []).forEach((field: string) => {
if (field.includes('.')) {
// Handle nested fields like 'spec.profile'
const [parent, child] = field.split('.');
if (!filtered[parent]) filtered[parent] = {};
filtered[parent][child] = (item as Record<string, any>)[parent]?.[
child
];
} else {
filtered[field] = item[field as keyof Entity];
}
});
return filtered as Entity;
});
return { items: filteredItems };
},
});
await renderInTestApp(
@@ -255,7 +277,7 @@ describe('MyGroupsSidebarItem Test', () => {
'relations.hasMember': 'user:default/guest',
},
],
fields: ['metadata', 'kind'],
fields: ['metadata', 'kind', 'spec.profile'],
});
});
});
@@ -300,7 +322,7 @@ describe('MyGroupsSidebarItem Test', () => {
'spec.type': 'team',
},
],
fields: ['metadata', 'kind'],
fields: ['metadata', 'kind', 'spec.profile'],
});
});
});
@@ -66,7 +66,7 @@ export const MyGroupsSidebarItem = (props: {
...(filter ?? {}),
},
],
fields: ['metadata', 'kind'],
fields: ['metadata', 'kind', 'spec.profile'],
});
return response.items;
}, []);
+3 -3
View File
@@ -31166,8 +31166,8 @@ __metadata:
linkType: hard
"glob@npm:^11.0.1":
version: 11.0.1
resolution: "glob@npm:11.0.1"
version: 11.0.2
resolution: "glob@npm:11.0.2"
dependencies:
foreground-child: "npm:^3.1.0"
jackspeak: "npm:^4.0.1"
@@ -31177,7 +31177,7 @@ __metadata:
path-scurry: "npm:^2.0.0"
bin:
glob: dist/esm/bin.mjs
checksum: 10/57b12a05cc25f1c38f3b24cf6ea7a8bacef11e782c4b9a8c5b0bef3e6c5bcb8c4548cb31eb4115592e0490a024c1bde7359c470565608dd061d3b21179740457
checksum: 10/53501530240150fdceb9ace47ab856acd1e0d598f8101b0760b665fc11dae2160d366563b89232ae4f5df7ddba8f7c92294719268fe932bd3a32d16cc58c3d02
languageName: node
linkType: hard