com.elestrago.unity.package-tools 2.2.3 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CAHNGELOG.md +22 -0
- package/Documentation~/api.md +230 -198
- package/Documentation~/manual.md +19 -19
- package/Documentation~/samples.md +44 -10
- package/Editor/Tools/FileTools.cs +11 -5
- package/README.md +39 -172
- package/Samples~/ClaudeSkills/unity-package-docs/SKILL.md +50 -8
- package/Samples~/ClaudeSkills/unity-package-docs/assets/api-chunk.md.template +10 -4
- package/Samples~/ClaudeSkills/unity-package-docs/assets/api.md.template +13 -4
- package/Samples~/ClaudeSkills/unity-package-docs/scripts/scan_package.py +50 -2
- package/Samples~/ClaudeSkills/unity-package-release/SKILL.md +373 -0
- package/Samples~/ClaudeSkills/unity-package-release/scripts/discover_package.py +366 -0
- package/package.json +4 -4
- package/Samples~/ClaudeSkills/unity-package-docs/unity-package-docs/SKILL.md +0 -309
- package/Samples~/ClaudeSkills/unity-package-docs/unity-package-docs/assets/README.md.template +0 -42
- package/Samples~/ClaudeSkills/unity-package-docs/unity-package-docs/assets/api-chunk.md.template +0 -41
- package/Samples~/ClaudeSkills/unity-package-docs/unity-package-docs/assets/api-index.md.template +0 -26
- package/Samples~/ClaudeSkills/unity-package-docs/unity-package-docs/assets/api.md.template +0 -43
- package/Samples~/ClaudeSkills/unity-package-docs/unity-package-docs/assets/manual.md.template +0 -57
- package/Samples~/ClaudeSkills/unity-package-docs/unity-package-docs/assets/samples.md.template +0 -56
- package/Samples~/ClaudeSkills/unity-package-docs/unity-package-docs/scripts/scan_package.py +0 -504
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""Discover the PackageManifestConfig.asset in a Unity-package repo and report
|
|
2
|
+
the fields and files the release skill needs to drive its pipeline.
|
|
3
|
+
|
|
4
|
+
The skill must not hardcode paths or line numbers because it is shipped to
|
|
5
|
+
multiple repos that all use the same `com.elestrago.unity.package-tools` editor
|
|
6
|
+
tool but differ in their layout, package name, changelog filename, README
|
|
7
|
+
location, and publish flow. Everything repo-specific is read at runtime.
|
|
8
|
+
|
|
9
|
+
Run from the repo root (default) or pass `--repo <path>`:
|
|
10
|
+
|
|
11
|
+
python3 .claude/skills/unity-package-release/scripts/discover_package.py
|
|
12
|
+
|
|
13
|
+
Output is a single JSON object on stdout. Errors go to stderr; non-zero exit
|
|
14
|
+
codes mean the caller cannot proceed safely.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# A PackageManifestConfig asset is a Unity ScriptableObject with a recognisable
|
|
30
|
+
# set of fields. We detect by signature rather than by path, since the canonical
|
|
31
|
+
# location varies between repos (the package-tool repo keeps it under
|
|
32
|
+
# Assets/PackageManifest/, but consumers are free to put it anywhere under
|
|
33
|
+
# Assets/).
|
|
34
|
+
CONFIG_SIGNATURE_FIELDS = (
|
|
35
|
+
"packageName:",
|
|
36
|
+
"packageVersion:",
|
|
37
|
+
"packageDestinationPath:",
|
|
38
|
+
"changelogPath:",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Top-level SO fields in Unity YAML are indented two spaces under `MonoBehaviour:`.
|
|
42
|
+
# Dependency entries are inside a `dependencies:` list and indent four spaces, so
|
|
43
|
+
# the rule "first line matching exactly `^ <field>: `" reliably picks the SO's
|
|
44
|
+
# own value and skips nested `packageVersion:` entries inside dependencies.
|
|
45
|
+
TOP_LEVEL_FIELD_RE = re.compile(r"^ (?P<field>[A-Za-z_][A-Za-z0-9_]*): ?(?P<value>.*)$")
|
|
46
|
+
|
|
47
|
+
# Inline-list `[a, b, c]` and `[]` show up for string-array fields. We don't
|
|
48
|
+
# parse them here — none of the fields the release skill cares about are arrays
|
|
49
|
+
# of primitives — but the regex above tolerates them by capturing the whole
|
|
50
|
+
# right-hand side as a string.
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def find_config_asset(repo: Path) -> Path | None:
|
|
54
|
+
"""Walk `<repo>/Assets/` for the first `.asset` file whose contents contain
|
|
55
|
+
all of the PackageManifestConfig signature fields. Returns None when no
|
|
56
|
+
candidate is found — the caller should refuse to proceed in that case."""
|
|
57
|
+
assets_dir = repo / "Assets"
|
|
58
|
+
if not assets_dir.is_dir():
|
|
59
|
+
return None
|
|
60
|
+
candidates: list[Path] = []
|
|
61
|
+
for path in assets_dir.rglob("*.asset"):
|
|
62
|
+
try:
|
|
63
|
+
head = path.read_text(encoding="utf-8", errors="replace")[:4096]
|
|
64
|
+
except OSError:
|
|
65
|
+
continue
|
|
66
|
+
if all(sig in head for sig in CONFIG_SIGNATURE_FIELDS):
|
|
67
|
+
candidates.append(path)
|
|
68
|
+
if not candidates:
|
|
69
|
+
return None
|
|
70
|
+
# If multiple configs exist (some repos package more than one), prefer the
|
|
71
|
+
# one nearest the repo root by path-segment count, then by name for
|
|
72
|
+
# determinism. Multi-package repos are out of scope for this skill — the
|
|
73
|
+
# user should pass --config-asset to disambiguate.
|
|
74
|
+
candidates.sort(key=lambda p: (len(p.relative_to(repo).parts), str(p)))
|
|
75
|
+
return candidates[0]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_config_fields(asset_path: Path) -> dict[str, Any]:
|
|
79
|
+
"""Extract the top-level scalar fields of a PackageManifestConfig asset.
|
|
80
|
+
Returns a dict mapping field-name → {"value": str, "line": int} for every
|
|
81
|
+
top-level field encountered, and a flat dict of values under "_values" for
|
|
82
|
+
convenience. Skips list-element fields under `dependencies:`, `samples:`,
|
|
83
|
+
`copyEntries:`, etc., because they sit at four-space indent."""
|
|
84
|
+
lines = asset_path.read_text(encoding="utf-8").splitlines()
|
|
85
|
+
fields: dict[str, dict[str, Any]] = {}
|
|
86
|
+
values: dict[str, str] = {}
|
|
87
|
+
for idx, raw in enumerate(lines, start=1):
|
|
88
|
+
m = TOP_LEVEL_FIELD_RE.match(raw)
|
|
89
|
+
if not m:
|
|
90
|
+
continue
|
|
91
|
+
name = m.group("field")
|
|
92
|
+
val = m.group("value").strip()
|
|
93
|
+
# Re-occurrences at top level shadow earlier ones; the SO YAML doesn't
|
|
94
|
+
# normally do that for scalars, but be defensive.
|
|
95
|
+
fields[name] = {"value": val, "line": idx}
|
|
96
|
+
values[name] = val
|
|
97
|
+
return {"_fields": fields, "_values": values}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def resolve_repo_relative(repo: Path, value: str) -> Path:
|
|
101
|
+
"""Mirror the runtime path resolution that
|
|
102
|
+
`FileTools.CopyOrReplaceFileToDirectory` uses: `Path.GetFullPath(value)` is
|
|
103
|
+
resolved against the current working directory, which Unity sets to the
|
|
104
|
+
project root. So a relative path in the asset means "relative to repo
|
|
105
|
+
root"."""
|
|
106
|
+
p = Path(value)
|
|
107
|
+
if p.is_absolute():
|
|
108
|
+
return p
|
|
109
|
+
return (repo / p).resolve()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def read_last_changelog_heading(changelog_path: Path) -> dict[str, Any] | None:
|
|
113
|
+
"""Read the first `## ` heading from the changelog and try to extract the
|
|
114
|
+
version and the URL it links to, so the skill can mirror the same format
|
|
115
|
+
when prepending a new entry. The skill should not assume any particular URL
|
|
116
|
+
host — it's free-form text per repo."""
|
|
117
|
+
if not changelog_path.is_file():
|
|
118
|
+
return None
|
|
119
|
+
heading_re = re.compile(r"^##\s+(.*)$")
|
|
120
|
+
version_in_heading_re = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
|
|
121
|
+
plain_version_re = re.compile(r"\[?([0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.+-]+)?)\]?")
|
|
122
|
+
try:
|
|
123
|
+
text = changelog_path.read_text(encoding="utf-8")
|
|
124
|
+
except OSError:
|
|
125
|
+
return None
|
|
126
|
+
for line in text.splitlines():
|
|
127
|
+
m = heading_re.match(line)
|
|
128
|
+
if not m:
|
|
129
|
+
continue
|
|
130
|
+
body = m.group(1).strip()
|
|
131
|
+
linked = version_in_heading_re.search(body)
|
|
132
|
+
if linked:
|
|
133
|
+
version = linked.group(1).strip()
|
|
134
|
+
url = linked.group(2).strip()
|
|
135
|
+
tag_url_format = url.replace(version, "{version}", 1) if version and version in url else None
|
|
136
|
+
return {
|
|
137
|
+
"heading": line.rstrip(),
|
|
138
|
+
"version": version,
|
|
139
|
+
"url": url,
|
|
140
|
+
"tag_url_format": tag_url_format,
|
|
141
|
+
}
|
|
142
|
+
# Heading without a markdown link — still useful for showing the user.
|
|
143
|
+
plain = plain_version_re.search(body)
|
|
144
|
+
return {
|
|
145
|
+
"heading": line.rstrip(),
|
|
146
|
+
"version": plain.group(1) if plain else None,
|
|
147
|
+
"url": None,
|
|
148
|
+
"tag_url_format": None,
|
|
149
|
+
}
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def find_readme_install_snippet(readme_path: Path, package_name: str) -> dict[str, Any] | None:
|
|
154
|
+
"""Find the consumer-install snippet line in the project-root README — the
|
|
155
|
+
one that looks like `"com.example.foo": "X.Y.Z"`. Returns the line number,
|
|
156
|
+
the version string, and the raw line so the skill can do a targeted rewrite
|
|
157
|
+
without touching anything else in the README. Returns None when the README
|
|
158
|
+
has no such snippet (some repos document installation differently)."""
|
|
159
|
+
if not readme_path.is_file():
|
|
160
|
+
return None
|
|
161
|
+
pattern = re.compile(r'"' + re.escape(package_name) + r'"\s*:\s*"([^"]+)"')
|
|
162
|
+
try:
|
|
163
|
+
text = readme_path.read_text(encoding="utf-8")
|
|
164
|
+
except OSError:
|
|
165
|
+
return None
|
|
166
|
+
for idx, line in enumerate(text.splitlines(), start=1):
|
|
167
|
+
m = pattern.search(line)
|
|
168
|
+
if m:
|
|
169
|
+
return {"line": idx, "version": m.group(1), "raw": line}
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def detect_publish_flow(repo: Path) -> dict[str, Any]:
|
|
174
|
+
"""Sniff out what publish flow this repo uses. The release skill prints a
|
|
175
|
+
different handoff block per flow, and the maintainer can override via the
|
|
176
|
+
`publish-flow=` skill arg. Detection is best-effort — when ambiguous, the
|
|
177
|
+
skill should ask the user rather than guessing."""
|
|
178
|
+
gitlab_ci = repo / ".gitlab-ci.yml"
|
|
179
|
+
github_workflows_dir = repo / ".github" / "workflows"
|
|
180
|
+
flow: dict[str, Any] = {
|
|
181
|
+
"gitlab_ci_present": gitlab_ci.is_file(),
|
|
182
|
+
"gitlab_ci_includes_elestrago_unity_npm": False,
|
|
183
|
+
"github_workflows_present": github_workflows_dir.is_dir() and any(
|
|
184
|
+
p.is_file() and p.suffix in {".yml", ".yaml"}
|
|
185
|
+
for p in github_workflows_dir.iterdir()
|
|
186
|
+
),
|
|
187
|
+
"github_workflow_files": [],
|
|
188
|
+
"detected_flow": "manual",
|
|
189
|
+
}
|
|
190
|
+
if gitlab_ci.is_file():
|
|
191
|
+
try:
|
|
192
|
+
ci_text = gitlab_ci.read_text(encoding="utf-8")
|
|
193
|
+
except OSError:
|
|
194
|
+
ci_text = ""
|
|
195
|
+
# The shared pipeline include is the signal the skill's original CI
|
|
196
|
+
# handoff block was written for. Any other GitLab CI setup we treat as
|
|
197
|
+
# "gitlab-generic" so the skill can degrade gracefully.
|
|
198
|
+
if "unity-package-npm-publish" in ci_text or "elestrago/ci-pipelines" in ci_text:
|
|
199
|
+
flow["gitlab_ci_includes_elestrago_unity_npm"] = True
|
|
200
|
+
if flow["github_workflows_present"]:
|
|
201
|
+
flow["github_workflow_files"] = sorted(
|
|
202
|
+
str(p.relative_to(repo)) for p in github_workflows_dir.iterdir()
|
|
203
|
+
if p.is_file() and p.suffix in {".yml", ".yaml"}
|
|
204
|
+
)
|
|
205
|
+
# Prioritise: the elestrago-CI signal is the most specific, then any other
|
|
206
|
+
# GitLab CI, then GitHub Actions, then manual.
|
|
207
|
+
if flow["gitlab_ci_includes_elestrago_unity_npm"]:
|
|
208
|
+
flow["detected_flow"] = "gitlab-elestrago-ci"
|
|
209
|
+
elif flow["gitlab_ci_present"]:
|
|
210
|
+
flow["detected_flow"] = "gitlab-generic"
|
|
211
|
+
elif flow["github_workflows_present"]:
|
|
212
|
+
flow["detected_flow"] = "github-actions"
|
|
213
|
+
else:
|
|
214
|
+
flow["detected_flow"] = "manual"
|
|
215
|
+
return flow
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def read_release_package_json(repo: Path, destination_path: str) -> dict[str, Any] | None:
|
|
219
|
+
"""Read the `package.json` inside the exported destination if it exists.
|
|
220
|
+
Some CI flows (notably gitlab-elestrago-ci) trigger publishing on a version
|
|
221
|
+
delta of this file, so the skill needs to know whether it exists and what
|
|
222
|
+
version it currently advertises — so it can warn when the user is about to
|
|
223
|
+
skip the Export Package Source step and silently break the publish."""
|
|
224
|
+
if not destination_path:
|
|
225
|
+
return None
|
|
226
|
+
dest = resolve_repo_relative(repo, destination_path)
|
|
227
|
+
package_json = dest / "package.json"
|
|
228
|
+
if not package_json.is_file():
|
|
229
|
+
return {
|
|
230
|
+
"path": str(package_json.relative_to(repo)) if dest.is_relative_to(repo) else str(package_json),
|
|
231
|
+
"present": False,
|
|
232
|
+
"version": None,
|
|
233
|
+
}
|
|
234
|
+
try:
|
|
235
|
+
import json as _json
|
|
236
|
+
data = _json.loads(package_json.read_text(encoding="utf-8"))
|
|
237
|
+
version = data.get("version") if isinstance(data, dict) else None
|
|
238
|
+
except (OSError, ValueError):
|
|
239
|
+
version = None
|
|
240
|
+
return {
|
|
241
|
+
"path": str(package_json.relative_to(repo)) if dest.is_relative_to(repo) else str(package_json),
|
|
242
|
+
"present": True,
|
|
243
|
+
"version": version,
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def read_git_state(repo: Path) -> dict[str, Any]:
|
|
248
|
+
"""Best-effort git state. Failures (missing git, not a repo) reduce to
|
|
249
|
+
empty/null fields rather than aborting — the skill can still do its job
|
|
250
|
+
without git, just without the convenience signals."""
|
|
251
|
+
def _run(args: list[str]) -> str | None:
|
|
252
|
+
try:
|
|
253
|
+
out = subprocess.check_output(
|
|
254
|
+
args, cwd=str(repo), stderr=subprocess.DEVNULL, text=True
|
|
255
|
+
)
|
|
256
|
+
return out.strip()
|
|
257
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
258
|
+
return None
|
|
259
|
+
return {
|
|
260
|
+
"branch": _run(["git", "rev-parse", "--abbrev-ref", "HEAD"]),
|
|
261
|
+
"remote_url": _run(["git", "remote", "get-url", "origin"]),
|
|
262
|
+
"recent_commits": _run(["git", "log", "--oneline", "-5"]),
|
|
263
|
+
"status_porcelain": _run(["git", "status", "--porcelain"]),
|
|
264
|
+
"diff_stat_head": _run(["git", "diff", "--stat", "HEAD"]),
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def discover(repo: Path, override_config: Path | None = None) -> dict[str, Any]:
|
|
269
|
+
asset = override_config if override_config else find_config_asset(repo)
|
|
270
|
+
if asset is None or not asset.is_file():
|
|
271
|
+
return {
|
|
272
|
+
"ok": False,
|
|
273
|
+
"error": (
|
|
274
|
+
"No PackageManifestConfig.asset found under Assets/. This skill "
|
|
275
|
+
"requires the com.elestrago.unity.package-tools editor tool to be "
|
|
276
|
+
"installed in the project (it provides the ScriptableObject this "
|
|
277
|
+
"skill drives)."
|
|
278
|
+
),
|
|
279
|
+
}
|
|
280
|
+
parsed = parse_config_fields(asset)
|
|
281
|
+
values = parsed["_values"]
|
|
282
|
+
fields = parsed["_fields"]
|
|
283
|
+
|
|
284
|
+
package_name = values.get("packageName", "")
|
|
285
|
+
package_version = values.get("packageVersion")
|
|
286
|
+
changelog_path_raw = values.get("changelogPath", "")
|
|
287
|
+
readme_path_raw = values.get("readmePath", "")
|
|
288
|
+
license_path_raw = values.get("licensePath", "")
|
|
289
|
+
documentation_path_raw = values.get("documentationPath", "")
|
|
290
|
+
destination_path_raw = values.get("packageDestinationPath", "")
|
|
291
|
+
|
|
292
|
+
changelog_resolved = resolve_repo_relative(repo, changelog_path_raw) if changelog_path_raw else None
|
|
293
|
+
readme_resolved = resolve_repo_relative(repo, readme_path_raw) if readme_path_raw else None
|
|
294
|
+
|
|
295
|
+
changelog_info = read_last_changelog_heading(changelog_resolved) if changelog_resolved else None
|
|
296
|
+
readme_snippet = (
|
|
297
|
+
find_readme_install_snippet(readme_resolved, package_name)
|
|
298
|
+
if readme_resolved and package_name else None
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
release_json_info = read_release_package_json(repo, destination_path_raw)
|
|
302
|
+
publish_flow = detect_publish_flow(repo)
|
|
303
|
+
git_state = read_git_state(repo)
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
"ok": True,
|
|
307
|
+
"repo": str(repo),
|
|
308
|
+
"config_asset": {
|
|
309
|
+
"path": str(asset.relative_to(repo)) if asset.is_relative_to(repo) else str(asset),
|
|
310
|
+
"package_version_line": fields.get("packageVersion", {}).get("line"),
|
|
311
|
+
},
|
|
312
|
+
"package": {
|
|
313
|
+
"name": package_name,
|
|
314
|
+
"display_name": values.get("displayName"),
|
|
315
|
+
"version": package_version,
|
|
316
|
+
"unity_version": values.get("unityVersion"),
|
|
317
|
+
"documentation_path": documentation_path_raw or None,
|
|
318
|
+
"destination_path": destination_path_raw or None,
|
|
319
|
+
},
|
|
320
|
+
"changelog": {
|
|
321
|
+
"path_in_config": changelog_path_raw or None,
|
|
322
|
+
"resolved_path": str(changelog_resolved.relative_to(repo)) if changelog_resolved and changelog_resolved.is_relative_to(repo) else (str(changelog_resolved) if changelog_resolved else None),
|
|
323
|
+
"exists": bool(changelog_resolved and changelog_resolved.is_file()),
|
|
324
|
+
"latest_heading": changelog_info,
|
|
325
|
+
},
|
|
326
|
+
"readme": {
|
|
327
|
+
"path_in_config": readme_path_raw or None,
|
|
328
|
+
"resolved_path": str(readme_resolved.relative_to(repo)) if readme_resolved and readme_resolved.is_relative_to(repo) else (str(readme_resolved) if readme_resolved else None),
|
|
329
|
+
"exists": bool(readme_resolved and readme_resolved.is_file()),
|
|
330
|
+
"install_snippet": readme_snippet,
|
|
331
|
+
},
|
|
332
|
+
"license": {
|
|
333
|
+
"path_in_config": license_path_raw or None,
|
|
334
|
+
},
|
|
335
|
+
"release_package_json": release_json_info,
|
|
336
|
+
"publish_flow": publish_flow,
|
|
337
|
+
"git": git_state,
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def main() -> int:
|
|
342
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
343
|
+
parser.add_argument(
|
|
344
|
+
"--repo",
|
|
345
|
+
default=os.getcwd(),
|
|
346
|
+
help="Repository root (defaults to current working directory).",
|
|
347
|
+
)
|
|
348
|
+
parser.add_argument(
|
|
349
|
+
"--config-asset",
|
|
350
|
+
default=None,
|
|
351
|
+
help=(
|
|
352
|
+
"Explicit path to PackageManifestConfig.asset. Use to disambiguate "
|
|
353
|
+
"in repos that ship multiple configs."
|
|
354
|
+
),
|
|
355
|
+
)
|
|
356
|
+
args = parser.parse_args()
|
|
357
|
+
repo = Path(args.repo).resolve()
|
|
358
|
+
override = Path(args.config_asset).resolve() if args.config_asset else None
|
|
359
|
+
result = discover(repo, override)
|
|
360
|
+
json.dump(result, sys.stdout, indent=2)
|
|
361
|
+
sys.stdout.write("\n")
|
|
362
|
+
return 0 if result.get("ok") else 2
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
if __name__ == "__main__":
|
|
366
|
+
sys.exit(main())
|
package/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "com.elestrago.unity.package-tools",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"displayName": "Package Tool",
|
|
5
5
|
"description": "Tool for create unity packages",
|
|
6
6
|
"category": "unity",
|
|
7
7
|
"unity": "2021.3",
|
|
8
8
|
"homepage": "https://gitlab.com/elestrago-pkg/package-tool",
|
|
9
|
-
"documentationUrl": "https://gitlab.com/elestrago-pkg/package-tool/-/blob/2.
|
|
10
|
-
"changelogUrl": "https://gitlab.com/elestrago-pkg/package-tool/-/blob/2.
|
|
11
|
-
"licensesUrl": "https://gitlab.com/elestrago-pkg/package-tool/-/blob/2.
|
|
9
|
+
"documentationUrl": "https://gitlab.com/elestrago-pkg/package-tool/-/blob/2.4.0/README.md",
|
|
10
|
+
"changelogUrl": "https://gitlab.com/elestrago-pkg/package-tool/-/blob/2.4.0/CHANGELOG.md",
|
|
11
|
+
"licensesUrl": "https://gitlab.com/elestrago-pkg/package-tool/-/blob/2.4.0/LICENSE",
|
|
12
12
|
"license": "MIT",
|
|
13
13
|
"keywords": [
|
|
14
14
|
"unity",
|