codz-cli 0.0.0-stage → 0.1.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/BETA.md +91 -0
- package/LICENSE +21 -0
- package/README.md +523 -2
- package/assets/gradle-wrapper/README.txt +2 -0
- package/assets/gradle-wrapper/gradle-wrapper.jar +0 -0
- package/assets/gradle-wrapper/gradlew +251 -0
- package/assets/gradle-wrapper/gradlew.bat +94 -0
- package/bin/codz.js +33 -0
- package/dist/adapters/android.js +321 -0
- package/dist/adapters/androidSetup.js +277 -0
- package/dist/adapters/common.js +114 -0
- package/dist/adapters/dartGen.js +83 -0
- package/dist/adapters/flutter.js +291 -0
- package/dist/adapters/flutterGen.js +204 -0
- package/dist/adapters/flutterScan.js +312 -0
- package/dist/adapters/flutterSetup.js +252 -0
- package/dist/adapters/flutterStyles.js +130 -0
- package/dist/adapters/generic.js +20 -0
- package/dist/adapters/getx.js +423 -0
- package/dist/adapters/index.js +28 -0
- package/dist/adapters/ios.js +440 -0
- package/dist/adapters/kotlinGen.js +110 -0
- package/dist/adapters/models.js +116 -0
- package/dist/adapters/nextjs.js +243 -0
- package/dist/adapters/nextjsGen.js +285 -0
- package/dist/adapters/nextjsScan.js +364 -0
- package/dist/adapters/nextjsSetup.js +119 -0
- package/dist/adapters/react.js +89 -0
- package/dist/adapters/reactNative.js +109 -0
- package/dist/adapters/reactNativeSetup.js +236 -0
- package/dist/adapters/reactQuery.js +89 -0
- package/dist/adapters/reactSetup.js +150 -0
- package/dist/adapters/source.js +165 -0
- package/dist/cli.js +1175 -0
- package/dist/core/cache.js +76 -0
- package/dist/core/changeset.js +404 -0
- package/dist/core/commands.js +239 -0
- package/dist/core/config.js +45 -0
- package/dist/core/diff.js +143 -0
- package/dist/core/events.js +41 -0
- package/dist/core/fsutil.js +122 -0
- package/dist/core/git.js +36 -0
- package/dist/core/jsonEdit.js +113 -0
- package/dist/core/merge.js +159 -0
- package/dist/core/proc.js +23 -0
- package/dist/core/safe.js +75 -0
- package/dist/core/templates.js +100 -0
- package/dist/core/tsAst.js +112 -0
- package/dist/core/types.js +12 -0
- package/dist/core/validateConfig.js +172 -0
- package/dist/core/workflow.js +72 -0
- package/dist/core/workspace.js +80 -0
- package/dist/core/yaml.js +126 -0
- package/dist/core/yamlDoc.js +367 -0
- package/dist/detector.js +95 -0
- package/dist/input/apiSpec.js +395 -0
- package/dist/input/curl.js +259 -0
- package/dist/input/endpoint.js +220 -0
- package/dist/input/har.js +120 -0
- package/dist/input/infer.js +174 -0
- package/dist/input/naming.js +145 -0
- package/dist/input/paste.js +130 -0
- package/dist/skills/androidSkeleton.js +292 -0
- package/dist/skills/androidTests.js +221 -0
- package/dist/skills/androidUi.js +561 -0
- package/dist/skills/androidViews.js +536 -0
- package/dist/skills/apiAdd.js +99 -0
- package/dist/skills/apiDiff.js +271 -0
- package/dist/skills/apiEdit.js +547 -0
- package/dist/skills/apiImport.js +127 -0
- package/dist/skills/apiList.js +35 -0
- package/dist/skills/apiSync.js +221 -0
- package/dist/skills/auth.js +1304 -0
- package/dist/skills/completion.js +142 -0
- package/dist/skills/config.js +93 -0
- package/dist/skills/create.js +358 -0
- package/dist/skills/doctor.js +304 -0
- package/dist/skills/doctorFix.js +214 -0
- package/dist/skills/explain.js +61 -0
- package/dist/skills/feature.js +418 -0
- package/dist/skills/featureEdit.js +225 -0
- package/dist/skills/featureLinks.js +179 -0
- package/dist/skills/init.js +66 -0
- package/dist/skills/iosUi.js +468 -0
- package/dist/skills/locale.js +335 -0
- package/dist/skills/mcp.js +175 -0
- package/dist/skills/mock.js +482 -0
- package/dist/skills/module.js +23 -0
- package/dist/skills/plans.js +66 -0
- package/dist/skills/prefill.js +84 -0
- package/dist/skills/reactNativeUi.js +388 -0
- package/dist/skills/reactUi.js +352 -0
- package/dist/skills/report.js +75 -0
- package/dist/skills/route.js +230 -0
- package/dist/skills/screen.js +597 -0
- package/dist/skills/screenForm.js +410 -0
- package/dist/skills/setup.js +28 -0
- package/dist/skills/setupDoctor.js +226 -0
- package/dist/skills/templatesCmd.js +32 -0
- package/dist/skills/testgen.js +331 -0
- package/dist/skills/theme.js +388 -0
- package/dist/starters/auth.js +144 -0
- package/dist/starters/commands.js +283 -0
- package/dist/starters/engine.js +315 -0
- package/dist/starters/home.js +42 -0
- package/dist/starters/http.js +93 -0
- package/dist/starters/manifest.js +285 -0
- package/dist/starters/paths.js +70 -0
- package/dist/starters/publish.js +150 -0
- package/dist/starters/registry.js +258 -0
- package/dist/starters/sign.js +72 -0
- package/dist/starters/source.js +98 -0
- package/dist/starters/strictJson.js +148 -0
- package/dist/starters/tar.js +180 -0
- package/docs/COMMANDS.md +527 -0
- package/examples/openapi.yaml +319 -0
- package/package.json +57 -4
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Detects what a pasted block contains: a cURL command, a URL, and JSON
|
|
2
|
+
// samples labelled as request or response. Decisions come from the shape of
|
|
3
|
+
// the data, never from free-text intent.
|
|
4
|
+
import { ToolError } from "../core/types.js";
|
|
5
|
+
const REQUEST_LABEL = /^(request|request body|body|payload|input)\s*:?\s*$/i;
|
|
6
|
+
const RESPONSE_LABEL = /^(response|response body|sample response|result|output)\s*:?\s*$/i;
|
|
7
|
+
/** Splits text at newlines that are outside shell quotes. */
|
|
8
|
+
function splitOutsideQuotes(text) {
|
|
9
|
+
const parts = [];
|
|
10
|
+
let cur = '';
|
|
11
|
+
let quote = null;
|
|
12
|
+
for (let i = 0; i < text.length; i++) {
|
|
13
|
+
const c = text[i];
|
|
14
|
+
if (quote) {
|
|
15
|
+
if (c === quote && text[i - 1] !== '\\')
|
|
16
|
+
quote = null;
|
|
17
|
+
cur += c;
|
|
18
|
+
}
|
|
19
|
+
else if (c === "'" || c === '"') {
|
|
20
|
+
quote = c;
|
|
21
|
+
cur += c;
|
|
22
|
+
}
|
|
23
|
+
else if (c === '\n') {
|
|
24
|
+
parts.push(cur);
|
|
25
|
+
cur = '';
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
cur += c;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
parts.push(cur);
|
|
32
|
+
return parts;
|
|
33
|
+
}
|
|
34
|
+
/** Reads consecutive JSON values from text, with optional label lines before each. */
|
|
35
|
+
function readJsonBlocks(text) {
|
|
36
|
+
const out = [];
|
|
37
|
+
let rest = text.trim();
|
|
38
|
+
let label = null;
|
|
39
|
+
while (rest.length) {
|
|
40
|
+
const firstLine = rest.split('\n', 1)[0].trim();
|
|
41
|
+
if (REQUEST_LABEL.test(firstLine) || RESPONSE_LABEL.test(firstLine)) {
|
|
42
|
+
label = REQUEST_LABEL.test(firstLine) ? 'request' : 'response';
|
|
43
|
+
rest = rest.slice(rest.indexOf('\n') + 1 || rest.length).trim();
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (rest[0] !== '{' && rest[0] !== '[') {
|
|
47
|
+
throw new ToolError('paste_parse', `Could not understand this part of the pasted input: "${firstLine.slice(0, 60)}"`, 'Paste a cURL command, a URL, or JSON samples. Label JSON with a "request:" or "response:" line when it is not obvious.');
|
|
48
|
+
}
|
|
49
|
+
const end = matchingBracket(rest);
|
|
50
|
+
const chunk = rest.slice(0, end + 1);
|
|
51
|
+
try {
|
|
52
|
+
out.push({ label, value: JSON.parse(chunk) });
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
throw new ToolError('paste_parse', `A pasted JSON block is not valid JSON: ${e.message}`);
|
|
56
|
+
}
|
|
57
|
+
label = null;
|
|
58
|
+
rest = rest.slice(end + 1).trim();
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
function matchingBracket(text) {
|
|
63
|
+
let depth = 0;
|
|
64
|
+
let inString = false;
|
|
65
|
+
for (let i = 0; i < text.length; i++) {
|
|
66
|
+
const c = text[i];
|
|
67
|
+
if (inString) {
|
|
68
|
+
if (c === '\\')
|
|
69
|
+
i++;
|
|
70
|
+
else if (c === '"')
|
|
71
|
+
inString = false;
|
|
72
|
+
}
|
|
73
|
+
else if (c === '"')
|
|
74
|
+
inString = true;
|
|
75
|
+
else if (c === '{' || c === '[')
|
|
76
|
+
depth++;
|
|
77
|
+
else if (c === '}' || c === ']') {
|
|
78
|
+
depth--;
|
|
79
|
+
if (depth === 0)
|
|
80
|
+
return i;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
throw new ToolError('paste_parse', 'A pasted JSON block is not closed.');
|
|
84
|
+
}
|
|
85
|
+
export function detectPaste(input) {
|
|
86
|
+
const text = input.replace(/\r\n/g, '\n').trim();
|
|
87
|
+
const result = { responseSamples: [] };
|
|
88
|
+
if (!text)
|
|
89
|
+
throw new ToolError('paste_empty', 'Nothing was pasted.');
|
|
90
|
+
let jsonPart = text;
|
|
91
|
+
if (/^curl\s/.test(text)) {
|
|
92
|
+
const lines = splitOutsideQuotes(text);
|
|
93
|
+
let cut = lines.length;
|
|
94
|
+
for (let i = 1; i < lines.length; i++) {
|
|
95
|
+
const prev = lines[i - 1].trimEnd();
|
|
96
|
+
const t = lines[i].trim();
|
|
97
|
+
if (prev.endsWith('\\'))
|
|
98
|
+
continue;
|
|
99
|
+
if (t === '' || t.startsWith('{') || t.startsWith('[') || REQUEST_LABEL.test(t) || RESPONSE_LABEL.test(t)) {
|
|
100
|
+
cut = i;
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
if (t.startsWith('-'))
|
|
104
|
+
continue; // a flag on its own line without a trailing backslash
|
|
105
|
+
cut = i;
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
result.curl = lines.slice(0, cut).join('\n');
|
|
109
|
+
jsonPart = lines.slice(cut).join('\n');
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
const firstLine = text.split('\n', 1)[0].trim();
|
|
113
|
+
const urlMatch = firstLine.match(/^(?:(GET|POST|PUT|PATCH|DELETE)\s+)?(https?:\/\/\S+)$/i);
|
|
114
|
+
if (urlMatch) {
|
|
115
|
+
result.url = firstLine;
|
|
116
|
+
jsonPart = text.slice(firstLine.length);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
for (const block of readJsonBlocks(jsonPart)) {
|
|
120
|
+
if (block.label === 'request') {
|
|
121
|
+
if (result.requestSample !== undefined)
|
|
122
|
+
throw new ToolError('paste_parse', 'More than one request sample was pasted.');
|
|
123
|
+
result.requestSample = block.value;
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
result.responseSamples.push(block.value);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// codz's minimal Android project: what Android Studio's "Empty Activity"
|
|
2
|
+
// template gives you (Kotlin, Jetpack Compose, a version catalog), plus
|
|
3
|
+
// Navigation Compose with a Home screen so screens can be added right away.
|
|
4
|
+
// Android has no command-line project creator, so create always uses this.
|
|
5
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { runTool } from "../core/proc.js";
|
|
10
|
+
export const ANDROID_SKELETON_VERSIONS = {
|
|
11
|
+
agp: '8.7.3',
|
|
12
|
+
kotlin: '2.0.21',
|
|
13
|
+
gradle: '8.11.1',
|
|
14
|
+
coreKtx: '1.15.0',
|
|
15
|
+
lifecycle: '2.8.7',
|
|
16
|
+
activityCompose: '1.9.3',
|
|
17
|
+
composeBom: '2024.10.01',
|
|
18
|
+
navigationCompose: '2.8.4',
|
|
19
|
+
junit: '4.13.2',
|
|
20
|
+
};
|
|
21
|
+
/** com.acme + shop-app → com.acme.shopapp */
|
|
22
|
+
export const androidPackage = (org, name) => `${org}.${name.replace(/[^a-z0-9]/g, '')}`;
|
|
23
|
+
const title = (name) => name.split(/[-_]/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join(' ');
|
|
24
|
+
const pascalName = (name) => title(name).replace(/\s/g, '');
|
|
25
|
+
export function androidSkeleton(o) {
|
|
26
|
+
const v = ANDROID_SKELETON_VERSIONS;
|
|
27
|
+
const pkg = androidPackage(o.org, o.name);
|
|
28
|
+
const dir = `app/src/main/java/${pkg.replace(/\./g, '/')}`;
|
|
29
|
+
const theme = `${pascalName(o.name)}Theme`;
|
|
30
|
+
const primary = (o.primary ?? '#6750A4').replace(/^#/, '').toUpperCase();
|
|
31
|
+
const f = new Map();
|
|
32
|
+
f.set('settings.gradle.kts', `pluginManagement {
|
|
33
|
+
repositories {
|
|
34
|
+
google {
|
|
35
|
+
content {
|
|
36
|
+
includeGroupByRegex("com\\\\.android.*")
|
|
37
|
+
includeGroupByRegex("com\\\\.google.*")
|
|
38
|
+
includeGroupByRegex("androidx.*")
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
mavenCentral()
|
|
42
|
+
gradlePluginPortal()
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
dependencyResolutionManagement {
|
|
46
|
+
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
|
47
|
+
repositories {
|
|
48
|
+
google()
|
|
49
|
+
mavenCentral()
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
rootProject.name = "${title(o.name)}"
|
|
54
|
+
include(":app")
|
|
55
|
+
`);
|
|
56
|
+
f.set('build.gradle.kts', `// Top-level build file: plugins are declared here and applied in the modules.
|
|
57
|
+
plugins {
|
|
58
|
+
alias(libs.plugins.android.application) apply false
|
|
59
|
+
alias(libs.plugins.kotlin.android) apply false
|
|
60
|
+
alias(libs.plugins.kotlin.compose) apply false
|
|
61
|
+
}
|
|
62
|
+
`);
|
|
63
|
+
f.set('gradle/libs.versions.toml', `[versions]
|
|
64
|
+
agp = "${v.agp}"
|
|
65
|
+
kotlin = "${v.kotlin}"
|
|
66
|
+
core-ktx = "${v.coreKtx}"
|
|
67
|
+
lifecycle = "${v.lifecycle}"
|
|
68
|
+
activity-compose = "${v.activityCompose}"
|
|
69
|
+
compose-bom = "${v.composeBom}"
|
|
70
|
+
navigation-compose = "${v.navigationCompose}"
|
|
71
|
+
junit = "${v.junit}"
|
|
72
|
+
|
|
73
|
+
[libraries]
|
|
74
|
+
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" }
|
|
75
|
+
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
|
76
|
+
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
|
77
|
+
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" }
|
|
78
|
+
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
|
|
79
|
+
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
|
80
|
+
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
|
81
|
+
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
|
82
|
+
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
|
83
|
+
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
|
84
|
+
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation-compose" }
|
|
85
|
+
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
|
86
|
+
|
|
87
|
+
[plugins]
|
|
88
|
+
android-application = { id = "com.android.application", version.ref = "agp" }
|
|
89
|
+
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
|
90
|
+
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
|
91
|
+
`);
|
|
92
|
+
f.set('gradle.properties', 'org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8\nandroid.useAndroidX=true\nkotlin.code.style=official\nandroid.nonTransitiveRClass=true\n');
|
|
93
|
+
f.set('gradle/wrapper/gradle-wrapper.properties', `distributionBase=GRADLE_USER_HOME\ndistributionPath=wrapper/dists\ndistributionUrl=https\\://services.gradle.org/distributions/gradle-${v.gradle}-bin.zip\nnetworkTimeout=10000\nvalidateDistributionUrl=true\nzipStoreBase=GRADLE_USER_HOME\nzipStorePath=wrapper/dists\n`);
|
|
94
|
+
f.set('.gitignore', '*.iml\n.gradle/\n/local.properties\n/.idea/\n.DS_Store\n/build/\n/app/build/\n/captures/\n.externalNativeBuild/\n.cxx/\n.kotlin/\n');
|
|
95
|
+
f.set('app/.gitignore', '/build\n');
|
|
96
|
+
f.set('app/proguard-rules.pro', '# Project-specific ProGuard rules go here.\n');
|
|
97
|
+
f.set('app/build.gradle.kts', `plugins {
|
|
98
|
+
alias(libs.plugins.android.application)
|
|
99
|
+
alias(libs.plugins.kotlin.android)
|
|
100
|
+
alias(libs.plugins.kotlin.compose)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
android {
|
|
104
|
+
namespace = "${pkg}"
|
|
105
|
+
compileSdk = 35
|
|
106
|
+
|
|
107
|
+
defaultConfig {
|
|
108
|
+
applicationId = "${pkg}"
|
|
109
|
+
minSdk = 24
|
|
110
|
+
targetSdk = 35
|
|
111
|
+
versionCode = 1
|
|
112
|
+
versionName = "1.0"
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
buildTypes {
|
|
116
|
+
release {
|
|
117
|
+
isMinifyEnabled = false
|
|
118
|
+
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
compileOptions {
|
|
122
|
+
sourceCompatibility = JavaVersion.VERSION_11
|
|
123
|
+
targetCompatibility = JavaVersion.VERSION_11
|
|
124
|
+
}
|
|
125
|
+
kotlinOptions {
|
|
126
|
+
jvmTarget = "11"
|
|
127
|
+
}
|
|
128
|
+
buildFeatures {
|
|
129
|
+
compose = true
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
dependencies {
|
|
134
|
+
implementation(libs.androidx.core.ktx)
|
|
135
|
+
implementation(libs.androidx.lifecycle.runtime.ktx)
|
|
136
|
+
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
|
137
|
+
implementation(libs.androidx.activity.compose)
|
|
138
|
+
implementation(platform(libs.androidx.compose.bom))
|
|
139
|
+
implementation(libs.androidx.ui)
|
|
140
|
+
implementation(libs.androidx.ui.graphics)
|
|
141
|
+
implementation(libs.androidx.ui.tooling.preview)
|
|
142
|
+
implementation(libs.androidx.material3)
|
|
143
|
+
implementation(libs.androidx.navigation.compose)
|
|
144
|
+
debugImplementation(libs.androidx.ui.tooling)
|
|
145
|
+
testImplementation(libs.junit)
|
|
146
|
+
}
|
|
147
|
+
`);
|
|
148
|
+
f.set('app/src/main/AndroidManifest.xml', `<?xml version="1.0" encoding="utf-8"?>
|
|
149
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
150
|
+
|
|
151
|
+
<application
|
|
152
|
+
android:allowBackup="true"
|
|
153
|
+
android:label="@string/app_name"
|
|
154
|
+
android:supportsRtl="true"
|
|
155
|
+
android:theme="@style/Theme.${pascalName(o.name)}">
|
|
156
|
+
<activity
|
|
157
|
+
android:name=".MainActivity"
|
|
158
|
+
android:exported="true"
|
|
159
|
+
android:theme="@style/Theme.${pascalName(o.name)}">
|
|
160
|
+
<intent-filter>
|
|
161
|
+
<action android:name="android.intent.action.MAIN" />
|
|
162
|
+
|
|
163
|
+
<category android:name="android.intent.category.LAUNCHER" />
|
|
164
|
+
</intent-filter>
|
|
165
|
+
</activity>
|
|
166
|
+
</application>
|
|
167
|
+
|
|
168
|
+
</manifest>
|
|
169
|
+
`);
|
|
170
|
+
f.set('app/src/main/res/values/strings.xml', `<resources>\n <string name="app_name">${title(o.name)}</string>\n</resources>\n`);
|
|
171
|
+
f.set('app/src/main/res/values/themes.xml', `<?xml version="1.0" encoding="utf-8"?>\n<resources>\n <style name="Theme.${pascalName(o.name)}" parent="android:Theme.Material.Light.NoActionBar" />\n</resources>\n`);
|
|
172
|
+
f.set(`${dir}/MainActivity.kt`, `package ${pkg}
|
|
173
|
+
|
|
174
|
+
import android.os.Bundle
|
|
175
|
+
import androidx.activity.ComponentActivity
|
|
176
|
+
import androidx.activity.compose.setContent
|
|
177
|
+
import androidx.activity.enableEdgeToEdge
|
|
178
|
+
import ${pkg}.ui.navigation.AppNavHost
|
|
179
|
+
import ${pkg}.ui.theme.${theme}
|
|
180
|
+
|
|
181
|
+
class MainActivity : ComponentActivity() {
|
|
182
|
+
override fun onCreate(savedInstanceState: Bundle?) {
|
|
183
|
+
super.onCreate(savedInstanceState)
|
|
184
|
+
enableEdgeToEdge()
|
|
185
|
+
setContent {
|
|
186
|
+
${theme} {
|
|
187
|
+
AppNavHost()
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
`);
|
|
193
|
+
f.set(`${dir}/ui/navigation/AppNavHost.kt`, `package ${pkg}.ui.navigation
|
|
194
|
+
|
|
195
|
+
import androidx.compose.runtime.Composable
|
|
196
|
+
import androidx.navigation.NavHostController
|
|
197
|
+
import androidx.navigation.compose.NavHost
|
|
198
|
+
import androidx.navigation.compose.composable
|
|
199
|
+
import androidx.navigation.compose.rememberNavController
|
|
200
|
+
import ${pkg}.ui.screens.HomeScreen
|
|
201
|
+
|
|
202
|
+
/** Every screen of the app. codz screen add and route add register new screens here. */
|
|
203
|
+
@Composable
|
|
204
|
+
fun AppNavHost(navController: NavHostController = rememberNavController()) {
|
|
205
|
+
NavHost(navController = navController, startDestination = "home") {
|
|
206
|
+
composable("home") {
|
|
207
|
+
HomeScreen()
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
`);
|
|
212
|
+
f.set(`${dir}/ui/screens/HomeScreen.kt`, `package ${pkg}.ui.screens
|
|
213
|
+
|
|
214
|
+
import androidx.compose.foundation.layout.Box
|
|
215
|
+
import androidx.compose.foundation.layout.fillMaxSize
|
|
216
|
+
import androidx.compose.foundation.layout.padding
|
|
217
|
+
import androidx.compose.material3.Scaffold
|
|
218
|
+
import androidx.compose.material3.Text
|
|
219
|
+
import androidx.compose.runtime.Composable
|
|
220
|
+
import androidx.compose.ui.Alignment
|
|
221
|
+
import androidx.compose.ui.Modifier
|
|
222
|
+
|
|
223
|
+
@Composable
|
|
224
|
+
fun HomeScreen() {
|
|
225
|
+
Scaffold { padding ->
|
|
226
|
+
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
|
|
227
|
+
Text("${title(o.name)}")
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
`);
|
|
232
|
+
f.set(`${dir}/ui/theme/Color.kt`, `package ${pkg}.ui.theme\n\nimport androidx.compose.ui.graphics.Color\n\nval Primary = Color(0xFF${primary})\n`);
|
|
233
|
+
f.set(`${dir}/ui/theme/Theme.kt`, `package ${pkg}.ui.theme
|
|
234
|
+
|
|
235
|
+
import androidx.compose.foundation.isSystemInDarkTheme
|
|
236
|
+
import androidx.compose.material3.MaterialTheme
|
|
237
|
+
import androidx.compose.material3.darkColorScheme
|
|
238
|
+
import androidx.compose.material3.lightColorScheme
|
|
239
|
+
import androidx.compose.runtime.Composable
|
|
240
|
+
|
|
241
|
+
@Composable
|
|
242
|
+
fun ${theme}(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
|
|
243
|
+
val colors = if (darkTheme) darkColorScheme(primary = Primary) else lightColorScheme(primary = Primary)
|
|
244
|
+
MaterialTheme(colorScheme = colors, content = content)
|
|
245
|
+
}
|
|
246
|
+
`);
|
|
247
|
+
return f;
|
|
248
|
+
}
|
|
249
|
+
/** gradlew, gradlew.bat and gradle-wrapper.jar shipped with codz (assets/gradle-wrapper). */
|
|
250
|
+
const BUNDLED_WRAPPER = fileURLToPath(new URL('../../assets/gradle-wrapper/', import.meta.url));
|
|
251
|
+
/**
|
|
252
|
+
* Adds gradlew, gradlew.bat and the wrapper jar: the copies shipped with codz,
|
|
253
|
+
* so Gradle does not need to be installed (the wrapper downloads the version in
|
|
254
|
+
* gradle-wrapper.properties on first run). Falls back to a local Gradle, run in
|
|
255
|
+
* an empty folder so it does not try to resolve the project's plugins. Returns
|
|
256
|
+
* false when neither is available.
|
|
257
|
+
*/
|
|
258
|
+
export function addGradleWrapper(dir) {
|
|
259
|
+
if (['gradlew', 'gradlew.bat', 'gradle-wrapper.jar'].every((f) => existsSync(join(BUNDLED_WRAPPER, f)))) {
|
|
260
|
+
mkdirSync(join(dir, 'gradle/wrapper'), { recursive: true });
|
|
261
|
+
copyFileSync(join(BUNDLED_WRAPPER, 'gradlew'), join(dir, 'gradlew'));
|
|
262
|
+
copyFileSync(join(BUNDLED_WRAPPER, 'gradlew.bat'), join(dir, 'gradlew.bat'));
|
|
263
|
+
copyFileSync(join(BUNDLED_WRAPPER, 'gradle-wrapper.jar'), join(dir, 'gradle/wrapper/gradle-wrapper.jar'));
|
|
264
|
+
try {
|
|
265
|
+
chmodSync(join(dir, 'gradlew'), 0o755);
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
// Windows.
|
|
269
|
+
}
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
const tmp = mkdtempSync(join(tmpdir(), 'codz-wrapper-'));
|
|
273
|
+
try {
|
|
274
|
+
writeFileSync(join(tmp, 'settings.gradle.kts'), 'rootProject.name = "wrapper"\n');
|
|
275
|
+
const r = runTool('gradle', ['wrapper', '--gradle-version', ANDROID_SKELETON_VERSIONS.gradle, '--distribution-type', 'bin', '-q', '--offline'], { cwd: tmp, timeout: 5 * 60_000 });
|
|
276
|
+
if (r.status !== 0 || !existsSync(join(tmp, 'gradlew')))
|
|
277
|
+
return false;
|
|
278
|
+
mkdirSync(join(dir, 'gradle/wrapper'), { recursive: true });
|
|
279
|
+
for (const p of ['gradlew', 'gradlew.bat', 'gradle/wrapper/gradle-wrapper.jar'])
|
|
280
|
+
copyFileSync(join(tmp, p), join(dir, p));
|
|
281
|
+
try {
|
|
282
|
+
chmodSync(join(dir, 'gradlew'), 0o755);
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
// Windows.
|
|
286
|
+
}
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
finally {
|
|
290
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
291
|
+
}
|
|
292
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// test add for Android: JUnit tests for Retrofit interfaces against OkHttp's
|
|
2
|
+
// MockWebServer, so they run on the JVM without a device or network.
|
|
3
|
+
//
|
|
4
|
+
// One test class per API interface (app/src/test/java/<package>/<Api>Test.kt).
|
|
5
|
+
// Each test enqueues example JSON built from the models, calls the method, and
|
|
6
|
+
// checks the request method and path and that the body parsed. A second test
|
|
7
|
+
// per interface checks that an HTTP 500 throws retrofit2.HttpException.
|
|
8
|
+
// testImplementation dependencies (mockwebserver, coroutines-test, junit) are
|
|
9
|
+
// added to the version catalog or build.gradle.kts when missing.
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { packageOf } from "../adapters/android.js";
|
|
12
|
+
import { gradleBlockAdd, tomlAdd } from "../adapters/androidSetup.js";
|
|
13
|
+
import { addKotlinImports, kotlinName } from "../adapters/kotlinGen.js";
|
|
14
|
+
import { buildChangeSet } from "../core/changeset.js";
|
|
15
|
+
import { readText } from "../core/fsutil.js";
|
|
16
|
+
import {} from "../core/types.js";
|
|
17
|
+
import { kotlinFun } from "./androidUi.js";
|
|
18
|
+
import { exampleFor } from "./mock.js";
|
|
19
|
+
const TEST_LIBS = [
|
|
20
|
+
{ key: 'okhttp-mockwebserver', group: 'com.squareup.okhttp3', name: 'mockwebserver', version: 'okhttp', value: '4.12.0' },
|
|
21
|
+
{ key: 'kotlinx-coroutines-test', group: 'org.jetbrains.kotlinx', name: 'kotlinx-coroutines-test', version: 'coroutines', value: '1.9.0' },
|
|
22
|
+
{ key: 'junit', group: 'junit', name: 'junit', version: 'junit', value: '4.13.2' },
|
|
23
|
+
];
|
|
24
|
+
const dirOf = (f) => f.slice(0, f.lastIndexOf('/'));
|
|
25
|
+
/** A Kotlin expression for an example value of a type (models become constructor calls). */
|
|
26
|
+
function kotlinValue(type, value, map, indent) {
|
|
27
|
+
const t = type.replace(/\?$/, '').trim();
|
|
28
|
+
if (value === null || value === undefined)
|
|
29
|
+
return type.endsWith('?') ? 'null' : t === 'String' ? '""' : t === 'Boolean' ? 'false' : /^(Int|Long|Double|Float)$/.test(t) ? '0' : 'TODO()';
|
|
30
|
+
const list = /^List<(.+)>$/.exec(t);
|
|
31
|
+
if (list)
|
|
32
|
+
return Array.isArray(value) && value.length ? `listOf(${value.map((v) => kotlinValue(list[1], v, map, indent)).join(', ')})` : 'emptyList()';
|
|
33
|
+
const model = map.models.find((m) => m.name === t);
|
|
34
|
+
if (model && typeof value === 'object') {
|
|
35
|
+
const inner = `${indent} `;
|
|
36
|
+
const args = model.fields.map((f) => `${inner}${kotlinName(f.name)} = ${kotlinValue(f.type + (f.nullable ? '?' : ''), value[f.name], map, inner)},`);
|
|
37
|
+
return `${t}(\n${args.join('\n')}\n${indent})`;
|
|
38
|
+
}
|
|
39
|
+
if (t === 'String')
|
|
40
|
+
return JSON.stringify(String(value)).replace(/\$/g, '\\$');
|
|
41
|
+
if (t === 'Long')
|
|
42
|
+
return `${Math.trunc(Number(value))}L`;
|
|
43
|
+
if (t === 'Double')
|
|
44
|
+
return Number.isInteger(value) ? `${value}.0` : String(value);
|
|
45
|
+
if (t === 'Float')
|
|
46
|
+
return `${value}f`;
|
|
47
|
+
return String(value);
|
|
48
|
+
}
|
|
49
|
+
function converter(ser) {
|
|
50
|
+
if (ser === 'moshi')
|
|
51
|
+
return { expr: 'MoshiConverterFactory.create(Moshi.Builder().build())', imports: ['com.squareup.moshi.Moshi', 'retrofit2.converter.moshi.MoshiConverterFactory'] };
|
|
52
|
+
if (ser === 'gson')
|
|
53
|
+
return { expr: 'GsonConverterFactory.create()', imports: ['retrofit2.converter.gson.GsonConverterFactory'] };
|
|
54
|
+
return { expr: 'Json { ignoreUnknownKeys = true }.asConverterFactory("application/json".toMediaType())', imports: ['kotlinx.serialization.json.Json', 'okhttp3.MediaType.Companion.toMediaType', 'retrofit2.converter.kotlinx.serialization.asConverterFactory'] };
|
|
55
|
+
}
|
|
56
|
+
export function androidTests(root, map, eps) {
|
|
57
|
+
const c = map.conventions;
|
|
58
|
+
const srcDir = c.srcDir || 'app/src/main/java';
|
|
59
|
+
const testDir = srcDir.replace(/\/main\//, '/test/');
|
|
60
|
+
const files = new Map();
|
|
61
|
+
const summary = [];
|
|
62
|
+
const warnings = [];
|
|
63
|
+
const read = (p) => files.get(p) ?? readText(join(root, p));
|
|
64
|
+
const conv = converter(c.serialization || 'kotlinx');
|
|
65
|
+
const byFile = new Map();
|
|
66
|
+
for (const ep of eps)
|
|
67
|
+
byFile.set(ep.file, [...(byFile.get(ep.file) ?? []), ep]);
|
|
68
|
+
for (const [apiFile, list] of byFile) {
|
|
69
|
+
const apiText = read(apiFile) ?? '';
|
|
70
|
+
const iface = /\binterface\s+(\w+)/.exec(apiText)?.[1];
|
|
71
|
+
if (!iface) {
|
|
72
|
+
warnings.push(`No interface found in ${apiFile}; skipped ${list.map((e) => e.name).join(', ')}.`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const pkg = packageOf(srcDir, dirOf(apiFile));
|
|
76
|
+
const testFile = `${testDir}/${pkg.replace(/\./g, '/')}/${iface}Test.kt`;
|
|
77
|
+
const existing = read(testFile);
|
|
78
|
+
const tests = [];
|
|
79
|
+
const imports = new Set();
|
|
80
|
+
for (const ep of list) {
|
|
81
|
+
if (existing && new RegExp(`fun\\s+\`?${ep.name}\\b`).test(existing)) {
|
|
82
|
+
summary.push(`${ep.name} already has a test in ${testFile}; skipped`);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const fun = kotlinFun(apiText, ep.name);
|
|
86
|
+
if (!fun) {
|
|
87
|
+
warnings.push(`Could not read ${ep.name} in ${apiFile}; skipped.`);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const values = new Map();
|
|
91
|
+
const args = [];
|
|
92
|
+
for (const p of fun.params) {
|
|
93
|
+
const name = p.name.replace(/`/g, '');
|
|
94
|
+
if (p.annotation === 'Body') {
|
|
95
|
+
const bodyType = p.type.replace(/\?$/, '');
|
|
96
|
+
const model = map.models.find((m) => m.name === bodyType);
|
|
97
|
+
if (model)
|
|
98
|
+
imports.add(`${packageOf(srcDir, dirOf(model.file))}.${model.name}`);
|
|
99
|
+
args.push(`${p.name} = ${kotlinValue(p.type, exampleFor(bodyType, map, name), map, ' ')}`);
|
|
100
|
+
}
|
|
101
|
+
else if (p.annotation === 'Path') {
|
|
102
|
+
const v = /^(Int|Long)$/.test(p.type) ? '1' : '"abc"';
|
|
103
|
+
values.set(p.wire ?? name, v.replace(/"/g, ''));
|
|
104
|
+
args.push(`${p.name} = ${v}`);
|
|
105
|
+
}
|
|
106
|
+
else if (!p.optional)
|
|
107
|
+
args.push(`${p.name} = ${kotlinValue(p.type, exampleFor(p.type, map, name), map, ' ')}`);
|
|
108
|
+
}
|
|
109
|
+
const unit = fun.returnType === 'Unit';
|
|
110
|
+
const body = unit ? '' : JSON.stringify(exampleFor(fun.returnType, map, ''));
|
|
111
|
+
const rel = /@(?:GET|POST|PUT|PATCH|DELETE|HEAD)\(\s*"([^"]*)"/.exec(apiText.slice(fun.start, fun.end))?.[1] ?? ep.path.replace(/^\//, '');
|
|
112
|
+
const reqPath = '/' + rel.replace(/^\//, '').replace(/\{([^}]+)\}/g, (_m, n) => values.get(n) ?? '1');
|
|
113
|
+
const call = `api.${ep.name}(${args.length > 1 || args.some((a) => a.includes('\n')) ? `\n ${args.join(',\n ')},\n ` : args.join('')})`;
|
|
114
|
+
tests.push([
|
|
115
|
+
' @Test',
|
|
116
|
+
` fun ${ep.name}() = runTest {`,
|
|
117
|
+
` server.enqueue(MockResponse().setResponseCode(200)${unit ? '' : `.setHeader("Content-Type", "application/json").setBody(${JSON.stringify(body).replace(/\$/g, '\\$')})`})`,
|
|
118
|
+
'',
|
|
119
|
+
unit ? ` ${call}` : ` val result = ${call}`,
|
|
120
|
+
'',
|
|
121
|
+
' val request = server.takeRequest()',
|
|
122
|
+
` assertEquals("${ep.method}", request.method)`,
|
|
123
|
+
` assertEquals("${reqPath}", request.path?.substringBefore('?'))`,
|
|
124
|
+
...(unit ? [] : [Array.isArray(JSON.parse(body)) ? ' assertEquals(2, result.size)' : ' assertNotNull(result)']),
|
|
125
|
+
' }',
|
|
126
|
+
].join('\n'));
|
|
127
|
+
summary.push(`Test ${ep.name}(): ${ep.method} ${reqPath}${unit ? '' : ', parses the example body'}`);
|
|
128
|
+
}
|
|
129
|
+
if (!tests.length)
|
|
130
|
+
continue;
|
|
131
|
+
const first = list[0];
|
|
132
|
+
const firstFun = kotlinFun(apiText, first.name);
|
|
133
|
+
if (!existing && firstFun) {
|
|
134
|
+
const args = firstFun.params.filter((p) => p.annotation === 'Path' || (p.annotation !== 'Body' && !p.optional)).map((p) => `${p.name} = ${/^(Int|Long)$/.test(p.type) ? '1' : '"abc"'}`);
|
|
135
|
+
const hasBody = firstFun.params.find((p) => p.annotation === 'Body');
|
|
136
|
+
if (!hasBody) {
|
|
137
|
+
tests.push([
|
|
138
|
+
' @Test',
|
|
139
|
+
` fun \`${first.name} throws HttpException on a server error\`() = runTest {`,
|
|
140
|
+
' server.enqueue(MockResponse().setResponseCode(500))',
|
|
141
|
+
'',
|
|
142
|
+
` val error = runCatching { api.${first.name}(${args.join(', ')}) }.exceptionOrNull()`,
|
|
143
|
+
'',
|
|
144
|
+
' assertTrue(error is HttpException)',
|
|
145
|
+
' assertEquals(500, (error as HttpException).code())',
|
|
146
|
+
' }',
|
|
147
|
+
].join('\n'));
|
|
148
|
+
imports.add('retrofit2.HttpException');
|
|
149
|
+
imports.add('org.junit.Assert.assertTrue');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const allImports = [...imports, ...conv.imports, `${pkg}.${iface}`, 'kotlinx.coroutines.test.runTest', 'okhttp3.mockwebserver.MockResponse', 'okhttp3.mockwebserver.MockWebServer', 'org.junit.After', 'org.junit.Assert.assertEquals', 'org.junit.Assert.assertNotNull', 'org.junit.Before', 'org.junit.Test', 'retrofit2.Retrofit'];
|
|
153
|
+
let text;
|
|
154
|
+
if (existing) {
|
|
155
|
+
const close = existing.lastIndexOf('}');
|
|
156
|
+
text = `${existing.slice(0, close).replace(/\s*$/, '\n')}\n${tests.join('\n\n')}\n}\n`;
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
text = [
|
|
160
|
+
`package ${pkg}`,
|
|
161
|
+
'',
|
|
162
|
+
`class ${iface}Test {`,
|
|
163
|
+
' private val server = MockWebServer()',
|
|
164
|
+
` private lateinit var api: ${iface}`,
|
|
165
|
+
'',
|
|
166
|
+
' @Before',
|
|
167
|
+
' fun setUp() {',
|
|
168
|
+
' server.start()',
|
|
169
|
+
' api = Retrofit.Builder()',
|
|
170
|
+
' .baseUrl(server.url("/"))',
|
|
171
|
+
` .addConverterFactory(${conv.expr})`,
|
|
172
|
+
' .build()',
|
|
173
|
+
` .create(${iface}::class.java)`,
|
|
174
|
+
' }',
|
|
175
|
+
'',
|
|
176
|
+
' @After',
|
|
177
|
+
' fun tearDown() {',
|
|
178
|
+
' server.shutdown()',
|
|
179
|
+
' }',
|
|
180
|
+
'',
|
|
181
|
+
tests.join('\n\n'),
|
|
182
|
+
'}',
|
|
183
|
+
'',
|
|
184
|
+
].join('\n');
|
|
185
|
+
}
|
|
186
|
+
text = addKotlinImports(text, allImports, pkg);
|
|
187
|
+
// assertNotNull only when used.
|
|
188
|
+
if (!/assertNotNull\(/.test(text.replace(/^import .*$/gm, '')))
|
|
189
|
+
text = text.replace(/^import org\.junit\.Assert\.assertNotNull\n/m, '');
|
|
190
|
+
files.set(testFile, text);
|
|
191
|
+
summary.unshift(`${existing ? 'Update' : 'Create'} ${testFile}`);
|
|
192
|
+
}
|
|
193
|
+
// testImplementation dependencies.
|
|
194
|
+
const module = c.module || 'app';
|
|
195
|
+
const build = [`${module}/build.gradle.kts`].find((f) => read(f) !== null);
|
|
196
|
+
const catalogPath = c.versionCatalog || '';
|
|
197
|
+
const catalog = catalogPath ? read(catalogPath) : null;
|
|
198
|
+
if (files.size && build) {
|
|
199
|
+
let b = read(build);
|
|
200
|
+
if (catalog !== null) {
|
|
201
|
+
let t = tomlAdd(catalog, 'versions', TEST_LIBS.map((l) => [l.version, `"${l.value}"`]));
|
|
202
|
+
t = tomlAdd(t, 'libraries', TEST_LIBS.map((l) => [l.key, `{ group = "${l.group}", name = "${l.name}", version.ref = "${l.version}" }`]));
|
|
203
|
+
if (t !== catalog) {
|
|
204
|
+
files.set(catalogPath, t);
|
|
205
|
+
summary.push(`Update ${catalogPath}: test libraries`);
|
|
206
|
+
}
|
|
207
|
+
b = gradleBlockAdd(b, 'dependencies', TEST_LIBS.map((l) => `testImplementation(libs.${l.key.replace(/-/g, '.')})`));
|
|
208
|
+
}
|
|
209
|
+
else
|
|
210
|
+
b = gradleBlockAdd(b, 'dependencies', TEST_LIBS.map((l) => `testImplementation("${l.group}:${l.name}:${l.value}")`));
|
|
211
|
+
if (b !== read(build)) {
|
|
212
|
+
files.set(build, b);
|
|
213
|
+
summary.push(`Update ${build}: testImplementation MockWebServer, coroutines-test, JUnit`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
else if (files.size)
|
|
217
|
+
warnings.push(`Add testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0"), kotlinx-coroutines-test and JUnit to your app module.`);
|
|
218
|
+
if (files.size)
|
|
219
|
+
summary.push(`Run them with: ./gradlew :${module}:testDebugUnitTest`);
|
|
220
|
+
return buildChangeSet(root, 'test add', { files, summary, warnings, generated: [] });
|
|
221
|
+
}
|