forked from clan/clan-core
Merge pull request 'API: improve type & class construction' (#1610) from hsjobeki/clan-core:hsjobeki-main into main
This commit is contained in:
commit
175b219246
@ -4,9 +4,11 @@ import logging
|
|||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
from dataclasses import fields, is_dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
from typing import Any
|
from types import UnionType
|
||||||
|
from typing import Any, get_args
|
||||||
|
|
||||||
import gi
|
import gi
|
||||||
from clan_cli.api import API
|
from clan_cli.api import API
|
||||||
@ -37,7 +39,7 @@ def dataclass_to_dict(obj: Any) -> Any:
|
|||||||
elif isinstance(obj, dict):
|
elif isinstance(obj, dict):
|
||||||
return {k: dataclass_to_dict(v) for k, v in obj.items()}
|
return {k: dataclass_to_dict(v) for k, v in obj.items()}
|
||||||
else:
|
else:
|
||||||
return obj
|
return str(obj)
|
||||||
|
|
||||||
|
|
||||||
# Implement the abstract open_file function
|
# Implement the abstract open_file function
|
||||||
@ -135,6 +137,58 @@ def open_file(file_request: FileRequest) -> str | None:
|
|||||||
return selected_path
|
return selected_path
|
||||||
|
|
||||||
|
|
||||||
|
def is_union_type(type_hint: type) -> bool:
|
||||||
|
return type(type_hint) is UnionType
|
||||||
|
|
||||||
|
|
||||||
|
def get_inner_type(type_hint: type) -> type:
|
||||||
|
if is_union_type(type_hint):
|
||||||
|
# Return the first non-None type
|
||||||
|
return next(t for t in get_args(type_hint) if t is not type(None))
|
||||||
|
return type_hint
|
||||||
|
|
||||||
|
|
||||||
|
def from_dict(t: type, data: dict[str, Any] | None) -> Any:
|
||||||
|
"""
|
||||||
|
Dynamically instantiate a data class from a dictionary, handling nested data classes.
|
||||||
|
"""
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Attempt to create an instance of the data_class
|
||||||
|
field_values = {}
|
||||||
|
for field in fields(t):
|
||||||
|
field_value = data.get(field.name)
|
||||||
|
field_type = get_inner_type(field.type)
|
||||||
|
if field_value is not None:
|
||||||
|
# If the field is another dataclass, recursively instantiate it
|
||||||
|
if is_dataclass(field_type):
|
||||||
|
field_value = from_dict(field_type, field_value)
|
||||||
|
elif isinstance(field_type, Path | str) and isinstance(
|
||||||
|
field_value, str
|
||||||
|
):
|
||||||
|
field_value = (
|
||||||
|
Path(field_value) if field_type == Path else field_value
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
field.default is not dataclasses.MISSING
|
||||||
|
or field.default_factory is not dataclasses.MISSING
|
||||||
|
):
|
||||||
|
# Field has a default value. We cannot set the value to None
|
||||||
|
if field_value is not None:
|
||||||
|
field_values[field.name] = field_value
|
||||||
|
else:
|
||||||
|
field_values[field.name] = field_value
|
||||||
|
|
||||||
|
return t(**field_values)
|
||||||
|
|
||||||
|
except (TypeError, ValueError) as e:
|
||||||
|
print(f"Failed to instantiate {t.__name__}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class WebView:
|
class WebView:
|
||||||
def __init__(self, methods: dict[str, Callable]) -> None:
|
def __init__(self, methods: dict[str, Callable]) -> None:
|
||||||
self.method_registry: dict[str, Callable] = methods
|
self.method_registry: dict[str, Callable] = methods
|
||||||
@ -216,14 +270,13 @@ class WebView:
|
|||||||
# But the js api returns dictionaries.
|
# But the js api returns dictionaries.
|
||||||
# Introspect the function and create the expected dataclass from dict dynamically
|
# Introspect the function and create the expected dataclass from dict dynamically
|
||||||
# Depending on the introspected argument_type
|
# Depending on the introspected argument_type
|
||||||
arg_type = API.get_method_argtype(method_name, k)
|
arg_class = API.get_method_argtype(method_name, k)
|
||||||
if dataclasses.is_dataclass(arg_type):
|
if dataclasses.is_dataclass(arg_class):
|
||||||
reconciled_arguments[k] = arg_type(**v)
|
reconciled_arguments[k] = from_dict(arg_class, v)
|
||||||
else:
|
else:
|
||||||
reconciled_arguments[k] = v
|
reconciled_arguments[k] = v
|
||||||
|
|
||||||
result = handler_fn(**reconciled_arguments)
|
result = handler_fn(**reconciled_arguments)
|
||||||
|
|
||||||
serialized = json.dumps(dataclass_to_dict(result))
|
serialized = json.dumps(dataclass_to_dict(result))
|
||||||
|
|
||||||
# Use idle_add to queue the response call to js on the main GTK thread
|
# Use idle_add to queue the response call to js on the main GTK thread
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import copy
|
import copy
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import pathlib
|
import pathlib
|
||||||
|
from dataclasses import MISSING
|
||||||
from types import NoneType, UnionType
|
from types import NoneType, UnionType
|
||||||
from typing import (
|
from typing import (
|
||||||
Annotated,
|
Annotated,
|
||||||
@ -77,20 +78,29 @@ def type_to_dict(t: Any, scope: str = "", type_map: dict[TypeVar, type] = {}) ->
|
|||||||
for f in fields
|
for f in fields
|
||||||
}
|
}
|
||||||
|
|
||||||
required = []
|
required = set()
|
||||||
for pn, pv in properties.items():
|
for pn, pv in properties.items():
|
||||||
if pv.get("type") is not None:
|
if pv.get("type") is not None:
|
||||||
if "null" not in pv["type"]:
|
if "null" not in pv["type"]:
|
||||||
required.append(pn)
|
required.add(pn)
|
||||||
|
|
||||||
elif pv.get("oneOf") is not None:
|
elif pv.get("oneOf") is not None:
|
||||||
if "null" not in [i["type"] for i in pv.get("oneOf", [])]:
|
if "null" not in [i["type"] for i in pv.get("oneOf", [])]:
|
||||||
required.append(pn)
|
required.add(pn)
|
||||||
|
|
||||||
|
required_fields = {
|
||||||
|
f.name
|
||||||
|
for f in fields
|
||||||
|
if f.default is MISSING and f.default_factory is MISSING
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find intersection
|
||||||
|
intersection = required & required_fields
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": properties,
|
"properties": properties,
|
||||||
"required": required,
|
"required": list(intersection),
|
||||||
# Dataclasses can only have the specified properties
|
# Dataclasses can only have the specified properties
|
||||||
"additionalProperties": False,
|
"additionalProperties": False,
|
||||||
}
|
}
|
||||||
|
@ -33,7 +33,7 @@ class ClanMetaInfo:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CreateOptions:
|
class CreateOptions:
|
||||||
directory: Path
|
directory: Path | str
|
||||||
# Metadata for the clan
|
# Metadata for the clan
|
||||||
# Metadata can be shown with `clan show`
|
# Metadata can be shown with `clan show`
|
||||||
meta: ClanMetaInfo | None = None
|
meta: ClanMetaInfo | None = None
|
||||||
@ -43,7 +43,7 @@ class CreateOptions:
|
|||||||
|
|
||||||
@API.register
|
@API.register
|
||||||
def create_clan(options: CreateOptions) -> CreateClanResponse:
|
def create_clan(options: CreateOptions) -> CreateClanResponse:
|
||||||
directory = options.directory
|
directory = Path(options.directory)
|
||||||
template_url = options.template_url
|
template_url = options.template_url
|
||||||
if not directory.exists():
|
if not directory.exists():
|
||||||
directory.mkdir()
|
directory.mkdir()
|
||||||
|
35
pkgs/clan-cli/clan_cli/clan/update.py
Normal file
35
pkgs/clan-cli/clan_cli/clan/update.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from clan_cli.api import API
|
||||||
|
from clan_cli.clan.create import ClanMetaInfo
|
||||||
|
from clan_cli.errors import ClanError
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UpdateOptions:
|
||||||
|
directory: str
|
||||||
|
meta: ClanMetaInfo | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@API.register
|
||||||
|
def update_clan_meta(options: UpdateOptions) -> ClanMetaInfo:
|
||||||
|
meta_file = Path(options.directory) / Path("clan/meta.json")
|
||||||
|
if not meta_file.exists():
|
||||||
|
raise ClanError(
|
||||||
|
"File not found",
|
||||||
|
description=f"Could not find {meta_file} to update.",
|
||||||
|
location="update_clan_meta",
|
||||||
|
)
|
||||||
|
|
||||||
|
meta_content: dict[str, str] = {}
|
||||||
|
with open(meta_file) as f:
|
||||||
|
meta_content = json.load(f)
|
||||||
|
|
||||||
|
meta_content = {**meta_content, **options.meta.__dict__}
|
||||||
|
|
||||||
|
with open(meta_file) as f:
|
||||||
|
json.dump(meta_content, f)
|
||||||
|
|
||||||
|
return ClanMetaInfo(**meta_content)
|
@ -22,6 +22,9 @@ export default tseslint.config(
|
|||||||
whitelist: ["material-icons"],
|
whitelist: ["material-icons"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
// TODO: make this more strict by removing later
|
||||||
|
"no-unused-vars": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": "off",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
43
pkgs/webview-ui/app/package-lock.json
generated
43
pkgs/webview-ui/app/package-lock.json
generated
@ -9,6 +9,8 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@modular-forms/solid": "^0.21.0",
|
||||||
|
"@tanstack/solid-query": "^5.44.0",
|
||||||
"material-icons": "^1.13.12",
|
"material-icons": "^1.13.12",
|
||||||
"solid-js": "^1.8.11",
|
"solid-js": "^1.8.11",
|
||||||
"solid-toast": "^0.5.0"
|
"solid-toast": "^0.5.0"
|
||||||
@ -1004,6 +1006,17 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@modular-forms/solid": {
|
||||||
|
"version": "0.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@modular-forms/solid/-/solid-0.21.0.tgz",
|
||||||
|
"integrity": "sha512-9QUykslNeVvn/gBj4iXoiNPV1UutS8sOCPGoqBxCNBwVQVjfeMljvXU3kZtctWEMy984q7JtKVVGSOXMDydW6A==",
|
||||||
|
"dependencies": {
|
||||||
|
"valibot": ">=0.31.0 <1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"solid-js": "^1.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nodelib/fs.scandir": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
@ -1535,6 +1548,31 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/query-core": {
|
||||||
|
"version": "5.44.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.44.0.tgz",
|
||||||
|
"integrity": "sha512-Fa1J7iEEyJnjXG1N4+Fz4OXNH/INve3XSn0Htms3X4wgRsXHxMDwqBE2XtDCts7swkwSIs4izEtaFqWVFr/eLQ==",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/solid-query": {
|
||||||
|
"version": "5.44.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/solid-query/-/solid-query-5.44.0.tgz",
|
||||||
|
"integrity": "sha512-fxsSFGbaHknL1zuymEZqrSRFk0wI3/RVDInlQJS3jZevnKJBAlDohIis6MZdm3EOFRTq+sfaMTNXZTu2B5MpOw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/query-core": "5.44.0",
|
||||||
|
"solid-js": "^1.8.17"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"solid-js": "^1.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
@ -5819,6 +5857,11 @@
|
|||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/valibot": {
|
||||||
|
"version": "0.31.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/valibot/-/valibot-0.31.1.tgz",
|
||||||
|
"integrity": "sha512-2YYIhPrnVSz/gfT2/iXVTrSj92HwchCt9Cga/6hX4B26iCz9zkIsGTS0HjDYTZfTi1Un0X6aRvhBi1cfqs/i0Q=="
|
||||||
|
},
|
||||||
"node_modules/validate-html-nesting": {
|
"node_modules/validate-html-nesting": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/validate-html-nesting/-/validate-html-nesting-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/validate-html-nesting/-/validate-html-nesting-1.2.2.tgz",
|
||||||
|
@ -37,6 +37,8 @@
|
|||||||
"vitest": "^1.6.0"
|
"vitest": "^1.6.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@modular-forms/solid": "^0.21.0",
|
||||||
|
"@tanstack/solid-query": "^5.44.0",
|
||||||
"material-icons": "^1.13.12",
|
"material-icons": "^1.13.12",
|
||||||
"solid-js": "^1.8.11",
|
"solid-js": "^1.8.11",
|
||||||
"solid-toast": "^0.5.0"
|
"solid-toast": "^0.5.0"
|
||||||
|
@ -8,7 +8,12 @@ import {
|
|||||||
createEffect,
|
createEffect,
|
||||||
createSignal,
|
createSignal,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import cx from "classnames";
|
import {
|
||||||
|
SubmitHandler,
|
||||||
|
createForm,
|
||||||
|
email,
|
||||||
|
required,
|
||||||
|
} from "@modular-forms/solid";
|
||||||
|
|
||||||
interface ClanDetailsProps {
|
interface ClanDetailsProps {
|
||||||
directory: string;
|
directory: string;
|
||||||
@ -23,9 +28,113 @@ interface MetaFieldsProps {
|
|||||||
|
|
||||||
const fn = (e: SubmitEvent) => {
|
const fn = (e: SubmitEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
console.log(e.currentTarget);
|
console.log("form submit", e.currentTarget);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const [, { Form, Field }] = createForm<ClanMeta>({
|
||||||
|
initialValues: { name: "MyClan" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit: SubmitHandler<ClanMeta> = (values, event) => {
|
||||||
|
pyApi.open_file.dispatch({ file_request: { mode: "save" } });
|
||||||
|
pyApi.open_file.receive((r) => {
|
||||||
|
if (r.status === "success") {
|
||||||
|
if (r.data) {
|
||||||
|
pyApi.create_clan.dispatch({
|
||||||
|
options: { directory: r.data, meta: values },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log("submit", values);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div class="card-body">
|
||||||
|
<Form onSubmit={handleSubmit}>
|
||||||
|
<Field
|
||||||
|
name="name"
|
||||||
|
validate={[required("Please enter a unique name for the clan.")]}
|
||||||
|
>
|
||||||
|
{(field, props) => (
|
||||||
|
<label class="form-control w-full max-w-xs">
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text block after:ml-0.5 after:text-primary after:content-['*']">
|
||||||
|
Name
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
{...props}
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
placeholder="your.mail@example.com"
|
||||||
|
class="input w-full max-w-xs"
|
||||||
|
classList={{ "input-error": !!field.error }}
|
||||||
|
value={field.value}
|
||||||
|
/>
|
||||||
|
<div class="label">
|
||||||
|
{field.error && (
|
||||||
|
<span class="label-text-alt">{field.error}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field name="description">
|
||||||
|
{(field, props) => (
|
||||||
|
<label class="form-control w-full max-w-xs">
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text">Description</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
{...props}
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
placeholder="Some words about your clan"
|
||||||
|
class="input w-full max-w-xs"
|
||||||
|
classList={{ "input-error": !!field.error }}
|
||||||
|
value={field.value || ""}
|
||||||
|
/>
|
||||||
|
<div class="label">
|
||||||
|
{field.error && (
|
||||||
|
<span class="label-text-alt">{field.error}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field name="icon">
|
||||||
|
{(field, props) => (
|
||||||
|
<label class="form-control w-full max-w-xs">
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text">Select icon</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
class="file-input file-input-bordered w-full max-w-xs"
|
||||||
|
/>
|
||||||
|
<div class="label">
|
||||||
|
{field.error && (
|
||||||
|
<span class="label-text-alt">{field.error}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<div class="card-actions justify-end">
|
||||||
|
<button class="btn btn-primary" type="submit">
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const EditMetaFields = (props: MetaFieldsProps) => {
|
export const EditMetaFields = (props: MetaFieldsProps) => {
|
||||||
const { meta, editable, actions, directory } = props;
|
const { meta, editable, actions, directory } = props;
|
||||||
|
|
||||||
@ -41,8 +150,9 @@ export const EditMetaFields = (props: MetaFieldsProps) => {
|
|||||||
/>
|
/>
|
||||||
</figure>
|
</figure>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form onSubmit={fn}>
|
<Login />
|
||||||
<h2 class="card-title justify-between">
|
{/* <form onSubmit={fn}> */}
|
||||||
|
{/* <h2 class="card-title justify-between">
|
||||||
<input
|
<input
|
||||||
classList={{
|
classList={{
|
||||||
[cx("text-slate-600")]: editing() !== "name",
|
[cx("text-slate-600")]: editing() !== "name",
|
||||||
@ -85,8 +195,8 @@ export const EditMetaFields = (props: MetaFieldsProps) => {
|
|||||||
<span>{directory}</span>
|
<span>{directory}</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
{actions}
|
{actions} */}
|
||||||
</form>
|
{/* </form> */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
npmDeps = pkgs.fetchNpmDeps {
|
npmDeps = pkgs.fetchNpmDeps {
|
||||||
src = ./app;
|
src = ./app;
|
||||||
hash = "sha256-NauXhPRKC80yiAeyt5mEBLcPZw3aheZDYXx4vLvpRvI=";
|
hash = "sha256-3LjcHh+jCuarh9XmS+mOv7xaGgAHxf3L7fWnxxmxUGQ=";
|
||||||
};
|
};
|
||||||
# The prepack script runs the build script, which we'd rather do in the build phase.
|
# The prepack script runs the build script, which we'd rather do in the build phase.
|
||||||
npmPackFlags = [ "--ignore-scripts" ];
|
npmPackFlags = [ "--ignore-scripts" ];
|
||||||
|
Loading…
Reference in New Issue
Block a user