@stacksjs/buddy 0.70.257 → 0.70.259
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/dist/cli.d.ts +11 -1
- package/dist/cli.js +5 -164
- package/dist/commands/about.js +1 -35
- package/dist/commands/add.js +1 -25
- package/dist/commands/ai-context.js +4 -27
- package/dist/commands/auth.js +1 -68
- package/dist/commands/build.js +1 -221
- package/dist/commands/cd.js +8 -39
- package/dist/commands/changelog.js +1 -32
- package/dist/commands/clean.js +1 -44
- package/dist/commands/cloud.js +2 -519
- package/dist/commands/commit.js +1 -16
- package/dist/commands/completion.js +6 -42
- package/dist/commands/config-migrate.js +6 -94
- package/dist/commands/configure.js +2 -45
- package/dist/commands/create.js +1 -162
- package/dist/commands/deploy.d.ts +10 -4
- package/dist/commands/deploy.js +13 -1664
- package/dist/commands/desktop-apple.js +12 -399
- package/dist/commands/dev.js +6 -1083
- package/dist/commands/dns.js +3 -103
- package/dist/commands/docs/buddy-commands.js +10 -132
- package/dist/commands/docs/buddy-commands.test.js +1 -38
- package/dist/commands/docs/framework-repo.js +1 -14
- package/dist/commands/docs/framework-repo.test.js +1 -68
- package/dist/commands/docs/generated-artifacts.js +3 -70
- package/dist/commands/docs/generated-artifacts.test.js +1 -19
- package/dist/commands/docs/links.js +2 -85
- package/dist/commands/docs/links.test.js +4 -66
- package/dist/commands/docs.js +1 -24
- package/dist/commands/doctor.js +2 -478
- package/dist/commands/domains.js +2 -152
- package/dist/commands/email.js +31 -503
- package/dist/commands/env.js +1 -312
- package/dist/commands/extension.js +1 -239
- package/dist/commands/features.js +9 -345
- package/dist/commands/fresh.js +1 -46
- package/dist/commands/generate.js +2 -152
- package/dist/commands/http.js +1 -33
- package/dist/commands/index.js +1 -57
- package/dist/commands/install.js +1 -22
- package/dist/commands/key.js +1 -23
- package/dist/commands/link.js +2 -147
- package/dist/commands/lint.js +2 -46
- package/dist/commands/list.js +1 -140
- package/dist/commands/mail.js +15 -1477
- package/dist/commands/maintenance.js +1 -141
- package/dist/commands/make.js +1 -392
- package/dist/commands/migrate-project.js +1 -49
- package/dist/commands/migrate.js +27 -625
- package/dist/commands/outdated.js +1 -19
- package/dist/commands/package.js +1 -17
- package/dist/commands/phone.js +17 -181
- package/dist/commands/ports.js +2 -118
- package/dist/commands/prepublish.js +1 -17
- package/dist/commands/projects.js +1 -29
- package/dist/commands/protocol/craft-evidence.js +4 -98
- package/dist/commands/protocol/craft-evidence.test.js +1 -20
- package/dist/commands/protocol/desktop-lifecycle-report.js +3 -57
- package/dist/commands/protocol/desktop-lifecycle-report.test.js +1 -32
- package/dist/commands/protocol/desktop-support.js +2 -32
- package/dist/commands/protocol/desktop-workflow.test.js +3 -35
- package/dist/commands/protocol/driver-registry.js +2 -71
- package/dist/commands/protocol/pantry-evidence.js +4 -144
- package/dist/commands/protocol/pantry-evidence.test.js +1 -29
- package/dist/commands/protocol/release-manifest.js +3 -102
- package/dist/commands/protocol/release-manifest.test.js +2 -34
- package/dist/commands/protocol/run-conformance.js +4 -346
- package/dist/commands/protocol/run-conformance.test.js +1 -52
- package/dist/commands/protocol/run-driver-contracts.js +4 -83
- package/dist/commands/protocol/run-driver-contracts.test.js +1 -18
- package/dist/commands/protocol/source-manifest.js +3 -113
- package/dist/commands/protocol/sync-suite.js +3 -136
- package/dist/commands/protocol/sync-suite.test.js +1 -44
- package/dist/commands/protocol.js +1 -60
- package/dist/commands/publish.js +8 -500
- package/dist/commands/queue.js +1 -248
- package/dist/commands/release.js +1 -42
- package/dist/commands/route.js +1 -21
- package/dist/commands/run-tool.js +1 -9
- package/dist/commands/saas.js +1 -25
- package/dist/commands/schedule.js +1 -61
- package/dist/commands/search.js +1 -84
- package/dist/commands/seed.js +1 -47
- package/dist/commands/serve.js +1 -55
- package/dist/commands/setup-ai.js +2 -121
- package/dist/commands/setup.js +2 -305
- package/dist/commands/share.js +1 -214
- package/dist/commands/sms.js +32 -319
- package/dist/commands/stacks.js +1 -72
- package/dist/commands/telemetry.js +1 -74
- package/dist/commands/test.js +1 -130
- package/dist/commands/tinker.js +1 -37
- package/dist/commands/types.js +1 -18
- package/dist/commands/upgrade.js +1 -98
- package/dist/commands/user.js +3 -84
- package/dist/commands/version.js +1 -14
- package/dist/config.js +1 -231
- package/dist/custom-cli.js +1 -23
- package/dist/database-preflight.js +1 -88
- package/dist/global-options.js +1 -4
- package/dist/index.js +1 -1
- package/dist/lazy-commands.js +1 -241
- package/dist/migrators/index.js +2 -62
- package/dist/migrators/laravel/index.js +1 -148
- package/dist/migrators/laravel/migrations.js +4 -231
- package/dist/migrators/laravel/models.js +8 -111
- package/dist/migrators/rails/index.js +1 -11
- package/dist/production-server.js +1 -257
- package/dist/project-setup.js +1 -36
- package/dist/serve-entry.js +1 -5
- package/dist/version-info.js +1 -5
- package/package.json +54 -47
package/dist/commands/env.js
CHANGED
|
@@ -1,312 +1 @@
|
|
|
1
|
-
import process from "node:process";
|
|
2
|
-
import { log, onUnknownSubcommand } from "@stacksjs/cli";
|
|
3
|
-
import { decryptEnv, encryptEnv, getEnv, getKeypair, resolveEnvFile, rotateKeypair, setEnv } from "@stacksjs/env";
|
|
4
|
-
import { ExitCode } from "@stacksjs/types";
|
|
5
|
-
export function env(buddy) {
|
|
6
|
-
const descriptions = {
|
|
7
|
-
env: "Interact with your environment variables",
|
|
8
|
-
get: "Get an environment variable",
|
|
9
|
-
set: "Set an environment variable",
|
|
10
|
-
encrypt: "Encrypt a value",
|
|
11
|
-
decrypt: "Decrypt a value",
|
|
12
|
-
check: "Check environment configuration and validate setup",
|
|
13
|
-
pretty: "Pretty print the result",
|
|
14
|
-
stdout: "Output the result to stdout",
|
|
15
|
-
keypair: "Generate a keypair",
|
|
16
|
-
fileKeys: "The path to the file containing the keys",
|
|
17
|
-
excludeKey: "The key to exclude from encryption",
|
|
18
|
-
rotate: "Rotate a keypair",
|
|
19
|
-
format: "The format to output the result (json, shell, eval)",
|
|
20
|
-
all: "Get all environment variables",
|
|
21
|
-
file: "The environment file to use",
|
|
22
|
-
project: "Target a specific project",
|
|
23
|
-
verbose: "Enable verbose output"
|
|
24
|
-
};
|
|
25
|
-
buddy.command("env:get [key]", descriptions.get).option("-f, --file [file]", descriptions.file, { default: "" }).option("--format [format]", descriptions.format, { default: "json" }).option("-a, --all", descriptions.all, { default: !1 }).option("-p, --pretty", descriptions.pretty, { default: !1 }).option("-p, --project [project]", descriptions.project, { default: !1 }).option("--verbose", descriptions.verbose, { default: !1 }).example("buddy env:get SECRET").example("buddy env:get SECRET --file .env.production").example("buddy env:get --all --pretty").example("buddy env:get --format shell").example("buddy env:get --format eval").example("buddy env:get --format json").action(async (key, options) => {
|
|
26
|
-
log.debug("Running `buddy env:get` ...", options);
|
|
27
|
-
const result = getEnv(key, {
|
|
28
|
-
file: resolveEnvFile(options.file, options.env),
|
|
29
|
-
keysFile: options.fileKeys,
|
|
30
|
-
all: options.all,
|
|
31
|
-
format: options.format,
|
|
32
|
-
prettyPrint: options.pretty
|
|
33
|
-
});
|
|
34
|
-
if (result.success) {
|
|
35
|
-
console.log(result.output);
|
|
36
|
-
process.exit(ExitCode.Success);
|
|
37
|
-
} else {
|
|
38
|
-
console.error(result.error);
|
|
39
|
-
process.exit(ExitCode.FatalError);
|
|
40
|
-
}
|
|
41
|
-
});
|
|
42
|
-
buddy.command("env:set [key] [value]", descriptions.set).option("-f, --file [file]", descriptions.file, { default: "" }).option("-fk, --file-keys [fileKeys]", descriptions.fileKeys, { default: "" }).option("--plain", "Don't encrypt the value", { default: !1 }).option("--verbose", descriptions.verbose, { default: !1 }).example("buddy env:set SECRET=value").example("buddy env:set SECRET value").example("buddy env:set SECRET value --file .env.production").example("buddy env:set SECRET value --plain").action(async (key, value, options) => {
|
|
43
|
-
log.debug("Running `buddy env:set` ...", options);
|
|
44
|
-
if (!key || !value) {
|
|
45
|
-
console.error("Both key and value are required");
|
|
46
|
-
process.exit(ExitCode.FatalError);
|
|
47
|
-
}
|
|
48
|
-
const result = setEnv(key, value, {
|
|
49
|
-
file: resolveEnvFile(options.file, options.env),
|
|
50
|
-
keysFile: options.fileKeys,
|
|
51
|
-
plain: options.plain
|
|
52
|
-
});
|
|
53
|
-
if (result.success) {
|
|
54
|
-
console.log(result.output);
|
|
55
|
-
process.exit(ExitCode.Success);
|
|
56
|
-
} else {
|
|
57
|
-
console.error(result.error);
|
|
58
|
-
process.exit(ExitCode.FatalError);
|
|
59
|
-
}
|
|
60
|
-
});
|
|
61
|
-
buddy.command("env:encrypt [key]", descriptions.encrypt).option("-f, --file [file]", descriptions.file, { default: "" }).option("-fk, --file-keys [fileKeys]", descriptions.fileKeys, { default: "" }).option("-o, --stdout", descriptions.stdout, { default: !1 }).option("-ek, --exclude-key [excludeKey]", descriptions.excludeKey, { default: "" }).example("buddy env:encrypt").example("buddy env:encrypt --file .env.production").example('buddy env:encrypt -k "SECRET_*"').action(async (key, options) => {
|
|
62
|
-
log.debug("Running `buddy env:encrypt` ...", options);
|
|
63
|
-
const result = encryptEnv({
|
|
64
|
-
file: resolveEnvFile(options.file, options.env),
|
|
65
|
-
keysFile: options.fileKeys,
|
|
66
|
-
key,
|
|
67
|
-
excludeKey: options.excludeKey,
|
|
68
|
-
stdout: options.stdout
|
|
69
|
-
});
|
|
70
|
-
if (result.success) {
|
|
71
|
-
console.log(result.output);
|
|
72
|
-
process.exit(ExitCode.Success);
|
|
73
|
-
} else {
|
|
74
|
-
console.error(result.error);
|
|
75
|
-
process.exit(ExitCode.FatalError);
|
|
76
|
-
}
|
|
77
|
-
});
|
|
78
|
-
buddy.command("env:decrypt [key]", descriptions.decrypt).option("-f, --file [file]", descriptions.file, { default: "" }).option("-fk, --file-keys [fileKeys]", descriptions.fileKeys, { default: "" }).option("-o, --stdout", descriptions.stdout, { default: !1 }).example("buddy env:decrypt").example("buddy env:decrypt --file .env.production").example('buddy env:decrypt -k "SECRET_*"').action(async (key, options) => {
|
|
79
|
-
log.debug("Running `buddy env:decrypt` ...", options);
|
|
80
|
-
const result = decryptEnv({
|
|
81
|
-
file: resolveEnvFile(options.file, options.env),
|
|
82
|
-
keysFile: options.fileKeys,
|
|
83
|
-
key,
|
|
84
|
-
stdout: options.stdout
|
|
85
|
-
});
|
|
86
|
-
if (result.success) {
|
|
87
|
-
console.log(result.output);
|
|
88
|
-
process.exit(ExitCode.Success);
|
|
89
|
-
} else {
|
|
90
|
-
console.error(result.error);
|
|
91
|
-
process.exit(ExitCode.FatalError);
|
|
92
|
-
}
|
|
93
|
-
});
|
|
94
|
-
buddy.command("env:keypair [key]", descriptions.keypair).option("-f, --file [file]", descriptions.file, { default: "" }).option("-fk, --file-keys [fileKeys]", descriptions.fileKeys, { default: "" }).option("--format [format]", descriptions.format, { default: "json" }).example("buddy env:keypair").example("buddy env:keypair --file .env.production").example("buddy env:keypair DOTENV_PRIVATE_KEY").action(async (key, options) => {
|
|
95
|
-
log.debug("Running `buddy env:keypair` ...", options);
|
|
96
|
-
const result = getKeypair(key, {
|
|
97
|
-
file: resolveEnvFile(options.file, options.env),
|
|
98
|
-
keysFile: options.fileKeys,
|
|
99
|
-
format: options.format
|
|
100
|
-
});
|
|
101
|
-
if (result.success) {
|
|
102
|
-
console.log(result.output);
|
|
103
|
-
process.exit(ExitCode.Success);
|
|
104
|
-
} else {
|
|
105
|
-
console.error(result.error);
|
|
106
|
-
process.exit(ExitCode.FatalError);
|
|
107
|
-
}
|
|
108
|
-
});
|
|
109
|
-
buddy.command("env:rotate [key]", descriptions.rotate).option("-f, --file [file]", descriptions.file, { default: "" }).option("-fk, --file-keys [fileKeys]", descriptions.fileKeys, { default: "" }).option("-o, --stdout", descriptions.stdout, { default: !1 }).option("-ek, --exclude-key [excludeKey]", descriptions.excludeKey, { default: "" }).example("buddy env:rotate").example("buddy env:rotate --file .env.production").action(async (key, options) => {
|
|
110
|
-
log.debug("Running `buddy env:rotate` ...", options);
|
|
111
|
-
const result = rotateKeypair({
|
|
112
|
-
file: resolveEnvFile(options.file, options.env),
|
|
113
|
-
keysFile: options.fileKeys,
|
|
114
|
-
key,
|
|
115
|
-
excludeKey: options.excludeKey,
|
|
116
|
-
stdout: options.stdout
|
|
117
|
-
});
|
|
118
|
-
if (result.success) {
|
|
119
|
-
console.log(result.output);
|
|
120
|
-
process.exit(ExitCode.Success);
|
|
121
|
-
} else {
|
|
122
|
-
console.error(result.error);
|
|
123
|
-
process.exit(ExitCode.FatalError);
|
|
124
|
-
}
|
|
125
|
-
});
|
|
126
|
-
buddy.command("env:check", descriptions.check).option("-f, --file [file]", descriptions.file, { default: "" }).option("--verbose", descriptions.verbose, { default: !1 }).example("buddy env:check").example("buddy env:check --file .env.production").action(async (options) => {
|
|
127
|
-
log.debug("Running `buddy env:check` ...", options);
|
|
128
|
-
const { bold, dim, green, red, yellow, intro } = await import("@stacksjs/cli"), { storage } = await import("@stacksjs/storage"), { existsSync } = await import("node:fs"), { resolve } = await import("node:path");
|
|
129
|
-
await intro("buddy env:check");
|
|
130
|
-
const checks = [], envFile = options.file || ".env", envPath = resolve(process.cwd(), envFile);
|
|
131
|
-
if (existsSync(envPath)) {
|
|
132
|
-
checks.push({
|
|
133
|
-
name: `${envFile} file`,
|
|
134
|
-
status: "pass",
|
|
135
|
-
message: "Found"
|
|
136
|
-
});
|
|
137
|
-
try {
|
|
138
|
-
const envContent = await storage.readTextFile(envPath), contentStr = typeof envContent === "string" ? envContent : envContent.data, values = parseEnvAssignments(contentStr), keys = Object.keys(values), varCount = keys.length;
|
|
139
|
-
checks.push({
|
|
140
|
-
name: "Environment variables",
|
|
141
|
-
status: "pass",
|
|
142
|
-
message: `${varCount} variables defined`
|
|
143
|
-
});
|
|
144
|
-
if ("APP_KEY" in values)
|
|
145
|
-
if ((values.APP_KEY ?? "").length > 0)
|
|
146
|
-
checks.push({
|
|
147
|
-
name: "APP_KEY",
|
|
148
|
-
status: "pass",
|
|
149
|
-
message: "Set"
|
|
150
|
-
});
|
|
151
|
-
else
|
|
152
|
-
checks.push({
|
|
153
|
-
name: "APP_KEY",
|
|
154
|
-
status: "warn",
|
|
155
|
-
message: "Empty (run: buddy key:generate)"
|
|
156
|
-
});
|
|
157
|
-
else
|
|
158
|
-
checks.push({
|
|
159
|
-
name: "APP_KEY",
|
|
160
|
-
status: "warn",
|
|
161
|
-
message: "Not found (run: buddy key:generate)"
|
|
162
|
-
});
|
|
163
|
-
const hasPublicKey = keys.some((key) => key.startsWith("DOTENV_PUBLIC_KEY")), hasPrivateKey = keys.some((key) => key.startsWith("DOTENV_PRIVATE_KEY")) || existsSync(resolve(process.cwd(), ".env.keys"));
|
|
164
|
-
if (hasPublicKey && hasPrivateKey)
|
|
165
|
-
checks.push({
|
|
166
|
-
name: "Encryption keys",
|
|
167
|
-
status: "pass",
|
|
168
|
-
message: "Public and private keys configured"
|
|
169
|
-
});
|
|
170
|
-
else if (hasPublicKey || hasPrivateKey)
|
|
171
|
-
checks.push({
|
|
172
|
-
name: "Encryption keys",
|
|
173
|
-
status: "warn",
|
|
174
|
-
message: "Incomplete keypair (run: buddy env:keypair)"
|
|
175
|
-
});
|
|
176
|
-
else
|
|
177
|
-
checks.push({
|
|
178
|
-
name: "Encryption keys",
|
|
179
|
-
status: "warn",
|
|
180
|
-
message: "Not configured (optional)"
|
|
181
|
-
});
|
|
182
|
-
const declaredTenants = await resolveDeclaredTenants();
|
|
183
|
-
if (declaredTenants.tenants.length === 0)
|
|
184
|
-
checks.push({
|
|
185
|
-
name: "Tenant isolation",
|
|
186
|
-
status: "pass",
|
|
187
|
-
message: "No tenants declared (cloud.tenants)"
|
|
188
|
-
});
|
|
189
|
-
else {
|
|
190
|
-
const { foreignTenantKeys, partitionTenantEnv } = await import("@stacksjs/env"), foreign = foreignTenantKeys(partitionTenantEnv(values, declaredTenants)), isShipped = /^\.env\.[a-z]+$/.test(envFile), total = foreign.reduce((sum, entry) => sum + entry.keys.length, 0);
|
|
191
|
-
if (foreign.length === 0)
|
|
192
|
-
checks.push({
|
|
193
|
-
name: "Tenant isolation",
|
|
194
|
-
status: "pass",
|
|
195
|
-
message: `No foreign keys (checked ${declaredTenants.tenants.join(", ")})`
|
|
196
|
-
});
|
|
197
|
-
else if (!isShipped)
|
|
198
|
-
checks.push({
|
|
199
|
-
name: "Tenant isolation",
|
|
200
|
-
status: "pass",
|
|
201
|
-
message: `${total} archived key(s) \u2014 ${envFile} is local-only and never deployed`
|
|
202
|
-
});
|
|
203
|
-
else {
|
|
204
|
-
checks.push({
|
|
205
|
-
name: "Tenant isolation",
|
|
206
|
-
status: "warn",
|
|
207
|
-
message: `${total} key(s) belong to another tenant \u2014 move them to .env`
|
|
208
|
-
});
|
|
209
|
-
for (const { tenant, keys } of foreign)
|
|
210
|
-
checks.push({
|
|
211
|
-
name: ` ${tenant}`,
|
|
212
|
-
status: "warn",
|
|
213
|
-
message: keys.join(", ")
|
|
214
|
-
});
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
} catch (error) {
|
|
218
|
-
checks.push({
|
|
219
|
-
name: `${envFile} content`,
|
|
220
|
-
status: "fail",
|
|
221
|
-
message: `Cannot read file: ${error}`
|
|
222
|
-
});
|
|
223
|
-
}
|
|
224
|
-
} else
|
|
225
|
-
checks.push({
|
|
226
|
-
name: `${envFile} file`,
|
|
227
|
-
status: "fail",
|
|
228
|
-
message: "Not found"
|
|
229
|
-
});
|
|
230
|
-
const keysPath = resolve(process.cwd(), ".env.keys");
|
|
231
|
-
if (existsSync(keysPath))
|
|
232
|
-
checks.push({
|
|
233
|
-
name: ".env.keys file",
|
|
234
|
-
status: "pass",
|
|
235
|
-
message: "Found"
|
|
236
|
-
});
|
|
237
|
-
else
|
|
238
|
-
checks.push({
|
|
239
|
-
name: ".env.keys file",
|
|
240
|
-
status: "warn",
|
|
241
|
-
message: "Not found (optional for encryption)"
|
|
242
|
-
});
|
|
243
|
-
console.log("");
|
|
244
|
-
console.log(bold("Environment Configuration Check:"));
|
|
245
|
-
console.log(dim("\u2500".repeat(60)));
|
|
246
|
-
console.log("");
|
|
247
|
-
let hasFailures = !1, hasWarnings = !1;
|
|
248
|
-
for (const check of checks) {
|
|
249
|
-
let statusIcon = "", statusColor = (text) => text;
|
|
250
|
-
if (check.status === "pass") {
|
|
251
|
-
statusIcon = "\u2713";
|
|
252
|
-
statusColor = green;
|
|
253
|
-
} else if (check.status === "warn") {
|
|
254
|
-
statusIcon = "\u26A0";
|
|
255
|
-
statusColor = yellow;
|
|
256
|
-
hasWarnings = !0;
|
|
257
|
-
} else {
|
|
258
|
-
statusIcon = "\u2717";
|
|
259
|
-
statusColor = red;
|
|
260
|
-
hasFailures = !0;
|
|
261
|
-
}
|
|
262
|
-
console.log(`${statusColor(statusIcon)} ${bold(check.name.padEnd(25))} ${dim(check.message)}`);
|
|
263
|
-
}
|
|
264
|
-
console.log("");
|
|
265
|
-
console.log(dim("\u2500".repeat(60)));
|
|
266
|
-
console.log("");
|
|
267
|
-
if (hasFailures) {
|
|
268
|
-
console.log(red("\u2717 Some critical checks failed. Please address the issues above."));
|
|
269
|
-
process.exit(ExitCode.FatalError);
|
|
270
|
-
} else if (hasWarnings) {
|
|
271
|
-
console.log(yellow("\u26A0 Some checks have warnings. Your environment should work but may have issues."));
|
|
272
|
-
process.exit(ExitCode.Success);
|
|
273
|
-
} else {
|
|
274
|
-
console.log(green("\u2713 All checks passed! Your environment configuration looks healthy."));
|
|
275
|
-
process.exit(ExitCode.Success);
|
|
276
|
-
}
|
|
277
|
-
});
|
|
278
|
-
onUnknownSubcommand(buddy, "env");
|
|
279
|
-
}
|
|
280
|
-
async function resolveDeclaredTenants() {
|
|
281
|
-
try {
|
|
282
|
-
const { config } = await import("@stacksjs/config"), cloud = config.cloud, app = config.app, tenants = Array.isArray(cloud?.tenants) ? cloud.tenants.filter((slug) => typeof slug === "string") : [];
|
|
283
|
-
return { self: app?.name, tenants };
|
|
284
|
-
} catch {
|
|
285
|
-
return { tenants: [] };
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
export function parseEnvAssignments(content) {
|
|
289
|
-
const values = {}, assignment = /^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/i, lines = content.split(/\r?\n/);
|
|
290
|
-
for (let i = 0;i < lines.length; i++) {
|
|
291
|
-
const line = lines[i];
|
|
292
|
-
if (!line.trim() || line.trimStart().startsWith("#"))
|
|
293
|
-
continue;
|
|
294
|
-
const match = line.match(assignment);
|
|
295
|
-
if (!match)
|
|
296
|
-
continue;
|
|
297
|
-
const [, key, first] = match;
|
|
298
|
-
let raw = first.trim();
|
|
299
|
-
const quote = raw[0] === '"' || raw[0] === "'" ? raw[0] : void 0;
|
|
300
|
-
if (quote) {
|
|
301
|
-
raw = raw.slice(1);
|
|
302
|
-
while (!raw.endsWith(quote) && i + 1 < lines.length) {
|
|
303
|
-
i++;
|
|
304
|
-
raw += lines[i];
|
|
305
|
-
}
|
|
306
|
-
raw = raw.endsWith(quote) ? raw.slice(0, -1) : raw;
|
|
307
|
-
} else
|
|
308
|
-
raw = raw.replace(/\s+#.*$/, "").trim();
|
|
309
|
-
values[key] = raw;
|
|
310
|
-
}
|
|
311
|
-
return values;
|
|
312
|
-
}
|
|
1
|
+
import process from"node:process";import{log,onUnknownSubcommand}from"@stacksjs/cli";import{decryptEnv,encryptEnv,getEnv,getKeypair,resolveEnvFile,rotateKeypair,setEnv}from"@stacksjs/env";import{ExitCode}from"@stacksjs/types";export function env(buddy){const descriptions={env:"Interact with your environment variables",get:"Get an environment variable",set:"Set an environment variable",encrypt:"Encrypt a value",decrypt:"Decrypt a value",check:"Check environment configuration and validate setup",pretty:"Pretty print the result",stdout:"Output the result to stdout",keypair:"Generate a keypair",fileKeys:"The path to the file containing the keys",excludeKey:"The key to exclude from encryption",rotate:"Rotate a keypair",format:"The format to output the result (json, shell, eval)",all:"Get all environment variables",file:"The environment file to use",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("env:get [key]",descriptions.get).option("-f, --file [file]",descriptions.file,{default:""}).option("--format [format]",descriptions.format,{default:"json"}).option("-a, --all",descriptions.all,{default:!1}).option("-p, --pretty",descriptions.pretty,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy env:get SECRET").example("buddy env:get SECRET --file .env.production").example("buddy env:get --all --pretty").example("buddy env:get --format shell").example("buddy env:get --format eval").example("buddy env:get --format json").action(async(key,options)=>{log.debug("Running `buddy env:get` ...",options);const result=getEnv(key,{file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,all:options.all,format:options.format,prettyPrint:options.pretty});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:set [key] [value]",descriptions.set).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("--plain","Don't encrypt the value",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy env:set SECRET=value").example("buddy env:set SECRET value").example("buddy env:set SECRET value --file .env.production").example("buddy env:set SECRET value --plain").action(async(key,value,options)=>{log.debug("Running `buddy env:set` ...",options);if(!key||!value){console.error("Both key and value are required");process.exit(ExitCode.FatalError)}const result=setEnv(key,value,{file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,plain:options.plain});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:encrypt [key]",descriptions.encrypt).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("-o, --stdout",descriptions.stdout,{default:!1}).option("-ek, --exclude-key [excludeKey]",descriptions.excludeKey,{default:""}).example("buddy env:encrypt").example("buddy env:encrypt --file .env.production").example('buddy env:encrypt -k "SECRET_*"').action(async(key,options)=>{log.debug("Running `buddy env:encrypt` ...",options);const result=encryptEnv({file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,key,excludeKey:options.excludeKey,stdout:options.stdout});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:decrypt [key]",descriptions.decrypt).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("-o, --stdout",descriptions.stdout,{default:!1}).example("buddy env:decrypt").example("buddy env:decrypt --file .env.production").example('buddy env:decrypt -k "SECRET_*"').action(async(key,options)=>{log.debug("Running `buddy env:decrypt` ...",options);const result=decryptEnv({file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,key,stdout:options.stdout});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:keypair [key]",descriptions.keypair).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("--format [format]",descriptions.format,{default:"json"}).example("buddy env:keypair").example("buddy env:keypair --file .env.production").example("buddy env:keypair DOTENV_PRIVATE_KEY").action(async(key,options)=>{log.debug("Running `buddy env:keypair` ...",options);const result=getKeypair(key,{file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,format:options.format});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:rotate [key]",descriptions.rotate).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("-o, --stdout",descriptions.stdout,{default:!1}).option("-ek, --exclude-key [excludeKey]",descriptions.excludeKey,{default:""}).example("buddy env:rotate").example("buddy env:rotate --file .env.production").action(async(key,options)=>{log.debug("Running `buddy env:rotate` ...",options);const result=rotateKeypair({file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,key,excludeKey:options.excludeKey,stdout:options.stdout});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:check",descriptions.check).option("-f, --file [file]",descriptions.file,{default:""}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy env:check").example("buddy env:check --file .env.production").action(async(options)=>{log.debug("Running `buddy env:check` ...",options);const{bold,dim,green,red,yellow,intro}=await import("@stacksjs/cli"),{storage}=await import("@stacksjs/storage"),{existsSync}=await import("node:fs"),{resolve}=await import("node:path");await intro("buddy env:check");const checks=[],envFile=options.file||".env",envPath=resolve(process.cwd(),envFile);if(existsSync(envPath)){checks.push({name:`${envFile} file`,status:"pass",message:"Found"});try{const envContent=await storage.readTextFile(envPath),contentStr=typeof envContent==="string"?envContent:envContent.data,values=parseEnvAssignments(contentStr),keys=Object.keys(values),varCount=keys.length;checks.push({name:"Environment variables",status:"pass",message:`${varCount} variables defined`});if("APP_KEY"in values)if((values.APP_KEY??"").length>0)checks.push({name:"APP_KEY",status:"pass",message:"Set"});else checks.push({name:"APP_KEY",status:"warn",message:"Empty (run: buddy key:generate)"});else checks.push({name:"APP_KEY",status:"warn",message:"Not found (run: buddy key:generate)"});const hasPublicKey=keys.some((key)=>key.startsWith("DOTENV_PUBLIC_KEY")),hasPrivateKey=keys.some((key)=>key.startsWith("DOTENV_PRIVATE_KEY"))||existsSync(resolve(process.cwd(),".env.keys"));if(hasPublicKey&&hasPrivateKey)checks.push({name:"Encryption keys",status:"pass",message:"Public and private keys configured"});else if(hasPublicKey||hasPrivateKey)checks.push({name:"Encryption keys",status:"warn",message:"Incomplete keypair (run: buddy env:keypair)"});else checks.push({name:"Encryption keys",status:"warn",message:"Not configured (optional)"});const declaredTenants=await resolveDeclaredTenants();if(declaredTenants.tenants.length===0)checks.push({name:"Tenant isolation",status:"pass",message:"No tenants declared (cloud.tenants)"});else{const{foreignTenantKeys,partitionTenantEnv}=await import("@stacksjs/env"),foreign=foreignTenantKeys(partitionTenantEnv(values,declaredTenants)),isShipped=/^\.env\.[a-z]+$/.test(envFile),total=foreign.reduce((sum,entry)=>sum+entry.keys.length,0);if(foreign.length===0)checks.push({name:"Tenant isolation",status:"pass",message:`No foreign keys (checked ${declaredTenants.tenants.join(", ")})`});else if(!isShipped)checks.push({name:"Tenant isolation",status:"pass",message:`${total} archived key(s) \u2014 ${envFile} is local-only and never deployed`});else{checks.push({name:"Tenant isolation",status:"warn",message:`${total} key(s) belong to another tenant \u2014 move them to .env`});for(const{tenant,keys}of foreign)checks.push({name:` ${tenant}`,status:"warn",message:keys.join(", ")})}}}catch(error){checks.push({name:`${envFile} content`,status:"fail",message:`Cannot read file: ${error}`})}}else checks.push({name:`${envFile} file`,status:"fail",message:"Not found"});const keysPath=resolve(process.cwd(),".env.keys");if(existsSync(keysPath))checks.push({name:".env.keys file",status:"pass",message:"Found"});else checks.push({name:".env.keys file",status:"warn",message:"Not found (optional for encryption)"});console.log("");console.log(bold("Environment Configuration Check:"));console.log(dim("\u2500".repeat(60)));console.log("");let hasFailures=!1,hasWarnings=!1;for(const check of checks){let statusIcon="",statusColor=(text)=>text;if(check.status==="pass"){statusIcon="\u2713";statusColor=green}else if(check.status==="warn"){statusIcon="\u26A0";statusColor=yellow;hasWarnings=!0}else{statusIcon="\u2717";statusColor=red;hasFailures=!0}console.log(`${statusColor(statusIcon)} ${bold(check.name.padEnd(25))} ${dim(check.message)}`)}console.log("");console.log(dim("\u2500".repeat(60)));console.log("");if(hasFailures){console.log(red("\u2717 Some critical checks failed. Please address the issues above."));process.exit(ExitCode.FatalError)}else if(hasWarnings){console.log(yellow("\u26A0 Some checks have warnings. Your environment should work but may have issues."));process.exit(ExitCode.Success)}else{console.log(green("\u2713 All checks passed! Your environment configuration looks healthy."));process.exit(ExitCode.Success)}});onUnknownSubcommand(buddy,"env")}async function resolveDeclaredTenants(){try{const{config}=await import("@stacksjs/config"),cloud=config.cloud,app=config.app,tenants=Array.isArray(cloud?.tenants)?cloud.tenants.filter((slug)=>typeof slug==="string"):[];return{self:app?.name,tenants}}catch{return{tenants:[]}}}export function parseEnvAssignments(content){const values={},assignment=/^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/i,lines=content.split(/\r?\n/);for(let i=0;i<lines.length;i++){const line=lines[i];if(!line.trim()||line.trimStart().startsWith("#"))continue;const match=line.match(assignment);if(!match)continue;const[,key,first]=match;let raw=first.trim();const quote=raw[0]==='"'||raw[0]==="'"?raw[0]:void 0;if(quote){raw=raw.slice(1);while(!raw.endsWith(quote)&&i+1<lines.length){i++;raw+=lines[i]}raw=raw.endsWith(quote)?raw.slice(0,-1):raw}else raw=raw.replace(/\s+#.*$/,"").trim();values[key]=raw}return values}
|
|
@@ -1,239 +1 @@
|
|
|
1
|
-
import process from "node:process";
|
|
2
|
-
import { log } from "@stacksjs/cli";
|
|
3
|
-
function parseSafariPlatforms(platform) {
|
|
4
|
-
if (!platform)
|
|
5
|
-
return;
|
|
6
|
-
if (platform === "all")
|
|
7
|
-
return ["macos", "ios"];
|
|
8
|
-
if (platform === "macos" || platform === "ios")
|
|
9
|
-
return [platform];
|
|
10
|
-
throw Error(`Invalid Safari platform ${platform}; use macos, ios, or all`);
|
|
11
|
-
}
|
|
12
|
-
export function extension(buddy) {
|
|
13
|
-
const load = async () => {
|
|
14
|
-
const { loadExtensionConfig } = await import("@stacksjs/browser-extension"), config = await loadExtensionConfig(process.cwd());
|
|
15
|
-
if (!config) {
|
|
16
|
-
log.error("No extension config found. Create `config/extension.ts` exporting `defineExtension({ \u2026 })`.");
|
|
17
|
-
process.exit(1);
|
|
18
|
-
}
|
|
19
|
-
const pkg = await Bun.file(`${process.cwd()}/package.json`).json().catch(() => ({}));
|
|
20
|
-
return { config, version: pkg.version ?? "0.0.0" };
|
|
21
|
-
};
|
|
22
|
-
buddy.command("extension:build", "Build the browser extension (Chrome + Firefox + Safari) from config/extension.ts").option("--target <target>", "Build a single target (chrome | firefox | safari); omit to build all").option("--version <version>", "Override the extension version (defaults to package.json)").action(async (options) => {
|
|
23
|
-
const { buildExtension, buildAllTargets } = await import("@stacksjs/browser-extension"), { config, version } = await load(), v = options.version ?? version;
|
|
24
|
-
if (options.target) {
|
|
25
|
-
const { outdir } = await buildExtension(config, { target: options.target, version: v });
|
|
26
|
-
log.success(`Built ${config.name} ${v} (${options.target}) \u2192 ${outdir}`);
|
|
27
|
-
} else {
|
|
28
|
-
await buildAllTargets(config, { version: v });
|
|
29
|
-
log.success(`Built ${config.name} ${v} for ${(config.targets ?? ["chrome", "firefox"]).join(", ")}`);
|
|
30
|
-
}
|
|
31
|
-
});
|
|
32
|
-
buddy.command("extension:init", "Scaffold a Chrome, Firefox, or Safari extension, including the Safari Xcode app").option("--name <name>", "Extension display name").option("--target <target>", "Scaffold chrome, firefox, safari, or all (default all)").option("--bundle-id <id>", "Safari container bundle identifier").option("--team-id <id>", "Apple Developer team used for Safari signing").option("--platform <platform>", "Safari platform: macos, ios, or all (default all)").option("--force", "Overwrite existing starter and Safari scaffold files").action(async (options) => {
|
|
33
|
-
const target = options.target ?? "all";
|
|
34
|
-
if (!["chrome", "firefox", "safari", "all"].includes(target))
|
|
35
|
-
throw Error(`Invalid extension target ${target}; use chrome, firefox, safari, or all`);
|
|
36
|
-
const { scaffoldExtensionProject } = await import("@stacksjs/browser-extension"), result = await scaffoldExtensionProject({
|
|
37
|
-
name: options.name,
|
|
38
|
-
target,
|
|
39
|
-
bundleId: options.bundleId,
|
|
40
|
-
teamId: options.teamId,
|
|
41
|
-
platforms: parseSafariPlatforms(options.platform) ?? ["macos", "ios"],
|
|
42
|
-
force: Boolean(options.force)
|
|
43
|
-
});
|
|
44
|
-
for (const file of result.written)
|
|
45
|
-
log.success(`created ${file}`);
|
|
46
|
-
for (const file of result.skipped)
|
|
47
|
-
log.info(`skip (exists): ${file}`);
|
|
48
|
-
if (result.safari)
|
|
49
|
-
log.success(`Scaffolded the Safari container app \u2192 ${result.safari.dir}`);
|
|
50
|
-
log.info("Next: add icons under public/icons, then run `buddy extension:build`.");
|
|
51
|
-
});
|
|
52
|
-
buddy.command("extension:package", "Build + zip the browser extension into store-ready archives").option("--target <target>", "Package a single target (chrome | firefox | safari); omit to package all").option("--version <version>", "Override the extension version (defaults to package.json)").action(async (options) => {
|
|
53
|
-
const { packageExtension } = await import("@stacksjs/browser-extension"), { config, version } = await load(), v = options.version ?? version, targets = options.target ? [options.target] : config.targets ?? ["chrome", "firefox"];
|
|
54
|
-
for (const target of targets) {
|
|
55
|
-
const out = await packageExtension(config, { target, version: v });
|
|
56
|
-
log.success(`Packaged ${config.name} (${target}) \u2192 ${out}`);
|
|
57
|
-
}
|
|
58
|
-
});
|
|
59
|
-
buddy.command("extension:chrome:status", "Fetch the Chrome Web Store item status").option("--service-account-path <path>", "Google service-account JSON key path").option("--access-token <token>", "Short-lived Chrome Web Store OAuth access token").action(async (options) => {
|
|
60
|
-
const { ChromeWebStoreClient } = await import("@stacksjs/browser-extension"), { config } = await load();
|
|
61
|
-
if (!config.chromeWebStore)
|
|
62
|
-
throw Error("Chrome status needs chromeWebStore.publisherId and chromeWebStore.itemId in config/extension.ts");
|
|
63
|
-
const status = await new ChromeWebStoreClient(options).fetchStatus(config.chromeWebStore);
|
|
64
|
-
log.info(`Chrome Web Store item ${status.itemId}`);
|
|
65
|
-
log.info(`published: ${status.publishedItemRevisionStatus?.state ?? "none"}`);
|
|
66
|
-
log.info(`submitted: ${status.submittedItemRevisionStatus?.state ?? "none"}`);
|
|
67
|
-
if (status.warned)
|
|
68
|
-
log.warn("Chrome has warned this item for a policy violation.");
|
|
69
|
-
if (status.takenDown)
|
|
70
|
-
log.error("Chrome has taken this item down for a policy violation.");
|
|
71
|
-
});
|
|
72
|
-
buddy.command("extension:chrome:publish", "Build, upload, and submit the Chrome extension through Web Store API v2").option("--version <version>", "Override the extension version (defaults to package.json)").option("--service-account-path <path>", "Google service-account JSON key path").option("--access-token <token>", "Short-lived Chrome Web Store OAuth access token").option("--upload-only", "Upload without submitting the item for review").option("--allow-warnings", "Submit even when Chrome reports validation warnings").action(async (options) => {
|
|
73
|
-
const { publishChromeExtension } = await import("@stacksjs/browser-extension"), { config, version } = await load(), result = await publishChromeExtension(config, {
|
|
74
|
-
version: options.version ?? version,
|
|
75
|
-
serviceAccountPath: options.serviceAccountPath,
|
|
76
|
-
accessToken: options.accessToken,
|
|
77
|
-
uploadOnly: Boolean(options.uploadOnly),
|
|
78
|
-
blockOnWarnings: !options.allowWarnings
|
|
79
|
-
});
|
|
80
|
-
if (result.deferred) {
|
|
81
|
-
log.warn(`${result.deferred.reason}. Version ${options.version ?? version} remains queued for the next automated retry.`);
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
|
-
if (result.alreadyPublished) {
|
|
85
|
-
log.success(result.alreadyPublished.reason);
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
log.success(`Uploaded Chrome package ${result.packagePath} (${result.upload?.crxVersion ?? "processing complete"})`);
|
|
89
|
-
if (result.publish)
|
|
90
|
-
log.success(`Submitted Chrome Web Store item ${result.publish.itemId}: ${result.publish.state}`);
|
|
91
|
-
});
|
|
92
|
-
buddy.command("extension:firefox:previews", "Sync the Firefox listing screenshots declared in config/extension.ts").option("--api-key <issuer>", "AMO JWT issuer").option("--api-secret <secret>", "AMO JWT secret").option("--dry-run", "Report what would change without touching the listing").action(async (options) => {
|
|
93
|
-
const { syncFirefoxPreviews } = await import("@stacksjs/browser-extension"), { config } = await load(), result = await syncFirefoxPreviews(config, {
|
|
94
|
-
issuer: options.apiKey,
|
|
95
|
-
secret: options.apiSecret,
|
|
96
|
-
dryRun: options.dryRun
|
|
97
|
-
});
|
|
98
|
-
if (result.unchanged)
|
|
99
|
-
log.info("Firefox listing screenshots already match config/extension.ts");
|
|
100
|
-
else if (options.dryRun)
|
|
101
|
-
log.info(`Would replace ${result.removed.length} Firefox listing screenshot(s)`);
|
|
102
|
-
else
|
|
103
|
-
log.success(`Synced ${result.uploaded.length} Firefox listing screenshot(s), removed ${result.removed.length}`);
|
|
104
|
-
});
|
|
105
|
-
buddy.command("extension:firefox:publish", "Build and submit the Firefox extension through Mozilla Add-ons").option("--version <version>", "Override the extension version (defaults to package.json)").option("--api-key <issuer>", "AMO JWT issuer").option("--api-secret <secret>", "AMO JWT secret").option("--source-code <path>", "Human-readable source archive for AMO review").option("--approval-timeout <milliseconds>", "How long to wait for human approval (default 0)").action(async (options) => {
|
|
106
|
-
const { publishFirefoxExtension } = await import("@stacksjs/browser-extension"), { config, version } = await load(), result = await publishFirefoxExtension(config, {
|
|
107
|
-
version: options.version ?? version,
|
|
108
|
-
issuer: options.apiKey,
|
|
109
|
-
secret: options.apiSecret,
|
|
110
|
-
sourceCodePath: options.sourceCode,
|
|
111
|
-
approvalTimeout: options.approvalTimeout === void 0 ? void 0 : Number(options.approvalTimeout)
|
|
112
|
-
});
|
|
113
|
-
log.success(`Submitted Firefox extension (${result.channel}) \u2192 ${result.artifactsDir}`);
|
|
114
|
-
if (config.firefoxAddons?.screenshots?.length)
|
|
115
|
-
try {
|
|
116
|
-
const { syncFirefoxPreviews } = await import("@stacksjs/browser-extension"), previews = await syncFirefoxPreviews(config, { issuer: options.apiKey, secret: options.apiSecret });
|
|
117
|
-
if (previews.unchanged)
|
|
118
|
-
log.info("Firefox listing screenshots already match");
|
|
119
|
-
else
|
|
120
|
-
log.success(`Synced ${previews.uploaded.length} Firefox listing screenshot(s), removed ${previews.removed.length}`);
|
|
121
|
-
} catch (error) {
|
|
122
|
-
log.warn(`[extension:firefox:publish] listing screenshots left as they were: ${error.message}`);
|
|
123
|
-
}
|
|
124
|
-
if (result.artifacts.length)
|
|
125
|
-
log.info(`new artifacts: ${result.artifacts.join(", ")}`);
|
|
126
|
-
});
|
|
127
|
-
buddy.command("extension:safari:provision", "Register Safari Bundle IDs and check the App Store Connect app record").option("--api-key-id <id>", "App Store Connect API key ID").option("--api-issuer-id <id>", "App Store Connect API issuer ID").option("--api-key-path <path>", "Path to the App Store Connect AuthKey_*.p8 file").option("--check", "Report missing resources without creating Bundle IDs").option("--version <version>", "Create or align App Store versions (defaults to package.json)").option("--platform <platform>", "Provision macos, ios, or all (defaults to config safariPlatforms)").action(async (options) => {
|
|
128
|
-
const { provisionSafariApp } = await import("@stacksjs/browser-extension"), { config, version } = await load(), result = await provisionSafariApp(config, {
|
|
129
|
-
keyId: options.apiKeyId,
|
|
130
|
-
issuerId: options.apiIssuerId,
|
|
131
|
-
keyPath: options.apiKeyPath,
|
|
132
|
-
checkOnly: Boolean(options.check),
|
|
133
|
-
version: options.version ?? version,
|
|
134
|
-
platforms: parseSafariPlatforms(options.platform)
|
|
135
|
-
});
|
|
136
|
-
for (const resource of [result.container, result.extension])
|
|
137
|
-
if (resource.created)
|
|
138
|
-
log.success(`Registered Bundle ID ${resource.identifier}`);
|
|
139
|
-
else if (resource.exists)
|
|
140
|
-
log.success(`Bundle ID exists: ${resource.identifier}`);
|
|
141
|
-
else
|
|
142
|
-
log.warn(`Bundle ID is missing: ${resource.identifier}`);
|
|
143
|
-
if (result.appRecord.exists)
|
|
144
|
-
log.success(`App Store Connect app record exists (${result.appRecord.id})`);
|
|
145
|
-
else
|
|
146
|
-
log.warn("App Store Connect app record is missing. Apple requires creating it in the App Store Connect website.");
|
|
147
|
-
for (const appStoreVersion of result.appStoreVersions) {
|
|
148
|
-
const action = appStoreVersion.created ? "Created" : appStoreVersion.updated ? "Updated" : "Ready";
|
|
149
|
-
log.success(`${action} Safari ${appStoreVersion.platform} App Store version ${appStoreVersion.version}`);
|
|
150
|
-
}
|
|
151
|
-
});
|
|
152
|
-
buddy.command("extension:safari:init", "Scaffold the Safari container app (Xcode project) from the template").option("--bundle-id <id>", "Base bundle identifier (defaults to config safariBundleId)").option("--dir <dir>", "Output directory for the Xcode project (default safari)").option("--force", "Overwrite existing scaffold files").option("--team-id <id>", "Apple Developer team used for signing").action(async (options) => {
|
|
153
|
-
const { scaffoldSafariApp } = await import("@stacksjs/browser-extension"), { config } = await load(), { dir, written, skipped } = await scaffoldSafariApp(config, {
|
|
154
|
-
bundleId: options.bundleId,
|
|
155
|
-
dir: options.dir,
|
|
156
|
-
force: Boolean(options.force),
|
|
157
|
-
teamId: options.teamId
|
|
158
|
-
});
|
|
159
|
-
log.success(`Scaffolded the Safari container app \u2192 ${dir} (${written.length} files)`);
|
|
160
|
-
if (skipped.length)
|
|
161
|
-
log.info(`kept ${skipped.length} existing files (use --force to overwrite)`);
|
|
162
|
-
});
|
|
163
|
-
buddy.command("extension:safari:app", "Build the extension and its macOS, iPhone, and iPad Safari container apps").option("--release", "Build the Release configuration (default Debug)").option("--signed", "Sign locally against the Apple ID in Xcode (local builds only \u2014 see below)").option("--skip-xcodebuild", "Only build + sync the extension payload").option("--version <version>", "Override the extension version (defaults to package.json)").option("--platform <platform>", "Build macos, ios, or all (defaults to config safariPlatforms)").action(async (options) => {
|
|
164
|
-
const { buildSafariApp, buildSafariUniversalApp } = await import("@stacksjs/browser-extension"), { config, version } = await load(), platforms = parseSafariPlatforms(options.platform) ?? config.safariPlatforms ?? ["macos"];
|
|
165
|
-
if (platforms.includes("ios")) {
|
|
166
|
-
const result = await buildSafariUniversalApp(config, {
|
|
167
|
-
version: options.version ?? version,
|
|
168
|
-
release: Boolean(options.release),
|
|
169
|
-
signed: Boolean(options.signed),
|
|
170
|
-
skipXcodebuild: Boolean(options.skipXcodebuild),
|
|
171
|
-
platforms
|
|
172
|
-
});
|
|
173
|
-
for (const platform of platforms) {
|
|
174
|
-
const appPath = result.appPaths[platform];
|
|
175
|
-
if (appPath)
|
|
176
|
-
log.success(`Built Safari ${platform} app ${appPath}`);
|
|
177
|
-
}
|
|
178
|
-
if (options.skipXcodebuild)
|
|
179
|
-
log.success(`Generated universal Safari project \u2192 ${result.project}`);
|
|
180
|
-
if (result.appPaths.macos)
|
|
181
|
-
log.info("Open the macOS app once, then enable the extension in Safari > Settings > Extensions.");
|
|
182
|
-
if (result.appPaths.ios)
|
|
183
|
-
log.info("Install the iOS app on an iPhone, iPad, or Simulator, then enable it in Settings > Apps > Safari > Extensions.");
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
const { appPath, resources } = await buildSafariApp(config, {
|
|
187
|
-
version: options.version ?? version,
|
|
188
|
-
release: Boolean(options.release),
|
|
189
|
-
signed: Boolean(options.signed),
|
|
190
|
-
skipXcodebuild: Boolean(options.skipXcodebuild)
|
|
191
|
-
});
|
|
192
|
-
if (appPath) {
|
|
193
|
-
log.success(`Built ${appPath}`);
|
|
194
|
-
log.info("Open the app once, then enable the extension in Safari > Settings > Extensions.");
|
|
195
|
-
} else
|
|
196
|
-
log.success(`Extension payload synced \u2192 ${resources}`);
|
|
197
|
-
});
|
|
198
|
-
buddy.command("extension:safari:publish", "Archive and validate or upload the Safari app to App Store Connect").option("--version <version>", "Override the marketing version (defaults to package.json)").option("--build-number <number>", "CFBundleVersion (defaults to GITHUB_RUN_NUMBER or Unix time)").option("--team-id <id>", "Apple Developer team (defaults to config safariTeamId)").option("--api-key-id <id>", "App Store Connect API key ID").option("--api-issuer-id <id>", "App Store Connect API issuer ID").option("--api-key-path <path>", "Path to the App Store Connect AuthKey_*.p8 file").option("--validate-only", "Create and validate the archive without uploading it").option("--platform <platform>", "Publish macos, ios, or all (defaults to config safariPlatforms)").option("--skip-screenshots", "Do not regenerate the App Store screenshot set before publishing").action(async (options) => {
|
|
199
|
-
const { publishSafariApp } = await import("@stacksjs/browser-extension"), { config, version } = await load();
|
|
200
|
-
if (!options.skipScreenshots)
|
|
201
|
-
try {
|
|
202
|
-
const { generateProjectImages } = await import("@stacksjs/actions");
|
|
203
|
-
await generateProjectImages({ only: ["app-store"] });
|
|
204
|
-
} catch (error) {
|
|
205
|
-
log.warn(`[extension:safari:publish] using the committed screenshots \u2014 could not regenerate: ${error.message}`);
|
|
206
|
-
}
|
|
207
|
-
const result = await publishSafariApp(config, {
|
|
208
|
-
version: options.version ?? version,
|
|
209
|
-
buildNumber: options.buildNumber,
|
|
210
|
-
teamId: options.teamId,
|
|
211
|
-
keyId: options.apiKeyId,
|
|
212
|
-
issuerId: options.apiIssuerId,
|
|
213
|
-
keyPath: options.apiKeyPath,
|
|
214
|
-
validateOnly: Boolean(options.validateOnly),
|
|
215
|
-
platforms: parseSafariPlatforms(options.platform)
|
|
216
|
-
});
|
|
217
|
-
for (const deferred of result.deferred)
|
|
218
|
-
log.warn(`${deferred.reason}. Version ${deferred.version} remains queued for the next automated retry.`);
|
|
219
|
-
for (const published of result.alreadyPublished)
|
|
220
|
-
log.success(`Safari ${published.platform} version ${published.version} is already published${published.state ? ` (${published.state})` : ""}`);
|
|
221
|
-
if (result.artifacts.length)
|
|
222
|
-
log.success(options.validateOnly ? `Validated Safari ${result.artifacts.map((artifact) => artifact.platform).join(" + ")} archives (build ${result.buildNumber})` : `Uploaded and selected Safari ${result.attachments.map((attachment) => attachment.platform).join(" + ")} build ${result.buildNumber} in App Store Connect`);
|
|
223
|
-
if (result.appStoreSubmission?.reviewSubmissionIds.length)
|
|
224
|
-
log.success(`Submitted ${result.appStoreSubmission.reviewSubmissionIds.length} Safari version(s) to App Review`);
|
|
225
|
-
});
|
|
226
|
-
buddy.command("extension:safari:submit", "Synchronize metadata and submit an existing Safari version to App Review").option("--version <version>", "Marketing version to submit (defaults to package.json)").option("--api-key-id <id>", "App Store Connect API key ID").option("--api-issuer-id <id>", "App Store Connect API issuer ID").option("--api-key-path <path>", "Path to the App Store Connect AuthKey_*.p8 file").option("--platform <platform>", "Submit macos, ios, or all (defaults to config safariPlatforms)").option("--prepare-only", "Synchronize the listing without submitting it for review").action(async (options) => {
|
|
227
|
-
const { submitSafariAppStore } = await import("@stacksjs/browser-extension"), { config, version } = await load(), result = await submitSafariAppStore(config, {
|
|
228
|
-
version: options.version ?? version,
|
|
229
|
-
keyId: options.apiKeyId,
|
|
230
|
-
issuerId: options.apiIssuerId,
|
|
231
|
-
keyPath: options.apiKeyPath,
|
|
232
|
-
platforms: parseSafariPlatforms(options.platform),
|
|
233
|
-
submit: !options.prepareOnly
|
|
234
|
-
});
|
|
235
|
-
log.success(`Synchronized ${result.versions.map((item) => item.platform).join(" + ")} App Store listings`);
|
|
236
|
-
if (result.reviewSubmissionIds.length)
|
|
237
|
-
log.success(`Submitted ${result.reviewSubmissionIds.length} Safari version(s) to App Review`);
|
|
238
|
-
});
|
|
239
|
-
}
|
|
1
|
+
import process from"node:process";import{log}from"@stacksjs/cli";function parseSafariPlatforms(platform){if(!platform)return;if(platform==="all")return["macos","ios"];if(platform==="macos"||platform==="ios")return[platform];throw Error(`Invalid Safari platform ${platform}; use macos, ios, or all`)}export function extension(buddy){const load=async()=>{const{loadExtensionConfig}=await import("@stacksjs/browser-extension"),config=await loadExtensionConfig(process.cwd());if(!config){log.error("No extension config found. Create `config/extension.ts` exporting `defineExtension({ \u2026 })`.");process.exit(1)}const pkg=await Bun.file(`${process.cwd()}/package.json`).json().catch(()=>({}));return{config,version:pkg.version??"0.0.0"}};buddy.command("extension:build","Build the browser extension (Chrome + Firefox + Safari) from config/extension.ts").option("--target <target>","Build a single target (chrome | firefox | safari); omit to build all").option("--version <version>","Override the extension version (defaults to package.json)").action(async(options)=>{const{buildExtension,buildAllTargets}=await import("@stacksjs/browser-extension"),{config,version}=await load(),v=options.version??version;if(options.target){const{outdir}=await buildExtension(config,{target:options.target,version:v});log.success(`Built ${config.name} ${v} (${options.target}) \u2192 ${outdir}`)}else{await buildAllTargets(config,{version:v});log.success(`Built ${config.name} ${v} for ${(config.targets??["chrome","firefox"]).join(", ")}`)}});buddy.command("extension:init","Scaffold a Chrome, Firefox, or Safari extension, including the Safari Xcode app").option("--name <name>","Extension display name").option("--target <target>","Scaffold chrome, firefox, safari, or all (default all)").option("--bundle-id <id>","Safari container bundle identifier").option("--team-id <id>","Apple Developer team used for Safari signing").option("--platform <platform>","Safari platform: macos, ios, or all (default all)").option("--force","Overwrite existing starter and Safari scaffold files").action(async(options)=>{const target=options.target??"all";if(!["chrome","firefox","safari","all"].includes(target))throw Error(`Invalid extension target ${target}; use chrome, firefox, safari, or all`);const{scaffoldExtensionProject}=await import("@stacksjs/browser-extension"),result=await scaffoldExtensionProject({name:options.name,target,bundleId:options.bundleId,teamId:options.teamId,platforms:parseSafariPlatforms(options.platform)??["macos","ios"],force:Boolean(options.force)});for(const file of result.written)log.success(`created ${file}`);for(const file of result.skipped)log.info(`skip (exists): ${file}`);if(result.safari)log.success(`Scaffolded the Safari container app \u2192 ${result.safari.dir}`);log.info("Next: add icons under public/icons, then run `buddy extension:build`.")});buddy.command("extension:package","Build + zip the browser extension into store-ready archives").option("--target <target>","Package a single target (chrome | firefox | safari); omit to package all").option("--version <version>","Override the extension version (defaults to package.json)").action(async(options)=>{const{packageExtension}=await import("@stacksjs/browser-extension"),{config,version}=await load(),v=options.version??version,targets=options.target?[options.target]:config.targets??["chrome","firefox"];for(const target of targets){const out=await packageExtension(config,{target,version:v});log.success(`Packaged ${config.name} (${target}) \u2192 ${out}`)}});buddy.command("extension:chrome:status","Fetch the Chrome Web Store item status").option("--service-account-path <path>","Google service-account JSON key path").option("--access-token <token>","Short-lived Chrome Web Store OAuth access token").action(async(options)=>{const{ChromeWebStoreClient}=await import("@stacksjs/browser-extension"),{config}=await load();if(!config.chromeWebStore)throw Error("Chrome status needs chromeWebStore.publisherId and chromeWebStore.itemId in config/extension.ts");const status=await new ChromeWebStoreClient(options).fetchStatus(config.chromeWebStore);log.info(`Chrome Web Store item ${status.itemId}`);log.info(`published: ${status.publishedItemRevisionStatus?.state??"none"}`);log.info(`submitted: ${status.submittedItemRevisionStatus?.state??"none"}`);if(status.warned)log.warn("Chrome has warned this item for a policy violation.");if(status.takenDown)log.error("Chrome has taken this item down for a policy violation.")});buddy.command("extension:chrome:publish","Build, upload, and submit the Chrome extension through Web Store API v2").option("--version <version>","Override the extension version (defaults to package.json)").option("--service-account-path <path>","Google service-account JSON key path").option("--access-token <token>","Short-lived Chrome Web Store OAuth access token").option("--upload-only","Upload without submitting the item for review").option("--allow-warnings","Submit even when Chrome reports validation warnings").action(async(options)=>{const{publishChromeExtension}=await import("@stacksjs/browser-extension"),{config,version}=await load(),result=await publishChromeExtension(config,{version:options.version??version,serviceAccountPath:options.serviceAccountPath,accessToken:options.accessToken,uploadOnly:Boolean(options.uploadOnly),blockOnWarnings:!options.allowWarnings});if(result.deferred){log.warn(`${result.deferred.reason}. Version ${options.version??version} remains queued for the next automated retry.`);return}if(result.alreadyPublished){log.success(result.alreadyPublished.reason);return}log.success(`Uploaded Chrome package ${result.packagePath} (${result.upload?.crxVersion??"processing complete"})`);if(result.publish)log.success(`Submitted Chrome Web Store item ${result.publish.itemId}: ${result.publish.state}`)});buddy.command("extension:firefox:previews","Sync the Firefox listing screenshots declared in config/extension.ts").option("--api-key <issuer>","AMO JWT issuer").option("--api-secret <secret>","AMO JWT secret").option("--dry-run","Report what would change without touching the listing").action(async(options)=>{const{syncFirefoxPreviews}=await import("@stacksjs/browser-extension"),{config}=await load(),result=await syncFirefoxPreviews(config,{issuer:options.apiKey,secret:options.apiSecret,dryRun:options.dryRun});if(result.unchanged)log.info("Firefox listing screenshots already match config/extension.ts");else if(options.dryRun)log.info(`Would replace ${result.removed.length} Firefox listing screenshot(s)`);else log.success(`Synced ${result.uploaded.length} Firefox listing screenshot(s), removed ${result.removed.length}`)});buddy.command("extension:firefox:publish","Build and submit the Firefox extension through Mozilla Add-ons").option("--version <version>","Override the extension version (defaults to package.json)").option("--api-key <issuer>","AMO JWT issuer").option("--api-secret <secret>","AMO JWT secret").option("--source-code <path>","Human-readable source archive for AMO review").option("--approval-timeout <milliseconds>","How long to wait for human approval (default 0)").action(async(options)=>{const{publishFirefoxExtension}=await import("@stacksjs/browser-extension"),{config,version}=await load(),result=await publishFirefoxExtension(config,{version:options.version??version,issuer:options.apiKey,secret:options.apiSecret,sourceCodePath:options.sourceCode,approvalTimeout:options.approvalTimeout===void 0?void 0:Number(options.approvalTimeout)});log.success(`Submitted Firefox extension (${result.channel}) \u2192 ${result.artifactsDir}`);if(config.firefoxAddons?.screenshots?.length)try{const{syncFirefoxPreviews}=await import("@stacksjs/browser-extension"),previews=await syncFirefoxPreviews(config,{issuer:options.apiKey,secret:options.apiSecret});if(previews.unchanged)log.info("Firefox listing screenshots already match");else log.success(`Synced ${previews.uploaded.length} Firefox listing screenshot(s), removed ${previews.removed.length}`)}catch(error){log.warn(`[extension:firefox:publish] listing screenshots left as they were: ${error.message}`)}if(result.artifacts.length)log.info(`new artifacts: ${result.artifacts.join(", ")}`)});buddy.command("extension:safari:provision","Register Safari Bundle IDs and check the App Store Connect app record").option("--api-key-id <id>","App Store Connect API key ID").option("--api-issuer-id <id>","App Store Connect API issuer ID").option("--api-key-path <path>","Path to the App Store Connect AuthKey_*.p8 file").option("--check","Report missing resources without creating Bundle IDs").option("--version <version>","Create or align App Store versions (defaults to package.json)").option("--platform <platform>","Provision macos, ios, or all (defaults to config safariPlatforms)").action(async(options)=>{const{provisionSafariApp}=await import("@stacksjs/browser-extension"),{config,version}=await load(),result=await provisionSafariApp(config,{keyId:options.apiKeyId,issuerId:options.apiIssuerId,keyPath:options.apiKeyPath,checkOnly:Boolean(options.check),version:options.version??version,platforms:parseSafariPlatforms(options.platform)});for(const resource of[result.container,result.extension])if(resource.created)log.success(`Registered Bundle ID ${resource.identifier}`);else if(resource.exists)log.success(`Bundle ID exists: ${resource.identifier}`);else log.warn(`Bundle ID is missing: ${resource.identifier}`);if(result.appRecord.exists)log.success(`App Store Connect app record exists (${result.appRecord.id})`);else log.warn("App Store Connect app record is missing. Apple requires creating it in the App Store Connect website.");for(const appStoreVersion of result.appStoreVersions){const action=appStoreVersion.created?"Created":appStoreVersion.updated?"Updated":"Ready";log.success(`${action} Safari ${appStoreVersion.platform} App Store version ${appStoreVersion.version}`)}});buddy.command("extension:safari:init","Scaffold the Safari container app (Xcode project) from the template").option("--bundle-id <id>","Base bundle identifier (defaults to config safariBundleId)").option("--dir <dir>","Output directory for the Xcode project (default safari)").option("--force","Overwrite existing scaffold files").option("--team-id <id>","Apple Developer team used for signing").action(async(options)=>{const{scaffoldSafariApp}=await import("@stacksjs/browser-extension"),{config}=await load(),{dir,written,skipped}=await scaffoldSafariApp(config,{bundleId:options.bundleId,dir:options.dir,force:Boolean(options.force),teamId:options.teamId});log.success(`Scaffolded the Safari container app \u2192 ${dir} (${written.length} files)`);if(skipped.length)log.info(`kept ${skipped.length} existing files (use --force to overwrite)`)});buddy.command("extension:safari:app","Build the extension and its macOS, iPhone, and iPad Safari container apps").option("--release","Build the Release configuration (default Debug)").option("--signed","Sign locally against the Apple ID in Xcode (local builds only \u2014 see below)").option("--skip-xcodebuild","Only build + sync the extension payload").option("--version <version>","Override the extension version (defaults to package.json)").option("--platform <platform>","Build macos, ios, or all (defaults to config safariPlatforms)").action(async(options)=>{const{buildSafariApp,buildSafariUniversalApp}=await import("@stacksjs/browser-extension"),{config,version}=await load(),platforms=parseSafariPlatforms(options.platform)??config.safariPlatforms??["macos"];if(platforms.includes("ios")){const result=await buildSafariUniversalApp(config,{version:options.version??version,release:Boolean(options.release),signed:Boolean(options.signed),skipXcodebuild:Boolean(options.skipXcodebuild),platforms});for(const platform of platforms){const appPath=result.appPaths[platform];if(appPath)log.success(`Built Safari ${platform} app ${appPath}`)}if(options.skipXcodebuild)log.success(`Generated universal Safari project \u2192 ${result.project}`);if(result.appPaths.macos)log.info("Open the macOS app once, then enable the extension in Safari > Settings > Extensions.");if(result.appPaths.ios)log.info("Install the iOS app on an iPhone, iPad, or Simulator, then enable it in Settings > Apps > Safari > Extensions.");return}const{appPath,resources}=await buildSafariApp(config,{version:options.version??version,release:Boolean(options.release),signed:Boolean(options.signed),skipXcodebuild:Boolean(options.skipXcodebuild)});if(appPath){log.success(`Built ${appPath}`);log.info("Open the app once, then enable the extension in Safari > Settings > Extensions.")}else log.success(`Extension payload synced \u2192 ${resources}`)});buddy.command("extension:safari:publish","Archive and validate or upload the Safari app to App Store Connect").option("--version <version>","Override the marketing version (defaults to package.json)").option("--build-number <number>","CFBundleVersion (defaults to GITHUB_RUN_NUMBER or Unix time)").option("--team-id <id>","Apple Developer team (defaults to config safariTeamId)").option("--api-key-id <id>","App Store Connect API key ID").option("--api-issuer-id <id>","App Store Connect API issuer ID").option("--api-key-path <path>","Path to the App Store Connect AuthKey_*.p8 file").option("--validate-only","Create and validate the archive without uploading it").option("--platform <platform>","Publish macos, ios, or all (defaults to config safariPlatforms)").option("--skip-screenshots","Do not regenerate the App Store screenshot set before publishing").action(async(options)=>{const{publishSafariApp}=await import("@stacksjs/browser-extension"),{config,version}=await load();if(!options.skipScreenshots)try{const{generateProjectImages}=await import("@stacksjs/actions");await generateProjectImages({only:["app-store"]})}catch(error){log.warn(`[extension:safari:publish] using the committed screenshots \u2014 could not regenerate: ${error.message}`)}const result=await publishSafariApp(config,{version:options.version??version,buildNumber:options.buildNumber,teamId:options.teamId,keyId:options.apiKeyId,issuerId:options.apiIssuerId,keyPath:options.apiKeyPath,validateOnly:Boolean(options.validateOnly),platforms:parseSafariPlatforms(options.platform)});for(const deferred of result.deferred)log.warn(`${deferred.reason}. Version ${deferred.version} remains queued for the next automated retry.`);for(const published of result.alreadyPublished)log.success(`Safari ${published.platform} version ${published.version} is already published${published.state?` (${published.state})`:""}`);if(result.artifacts.length)log.success(options.validateOnly?`Validated Safari ${result.artifacts.map((artifact)=>artifact.platform).join(" + ")} archives (build ${result.buildNumber})`:`Uploaded and selected Safari ${result.attachments.map((attachment)=>attachment.platform).join(" + ")} build ${result.buildNumber} in App Store Connect`);if(result.appStoreSubmission?.reviewSubmissionIds.length)log.success(`Submitted ${result.appStoreSubmission.reviewSubmissionIds.length} Safari version(s) to App Review`)});buddy.command("extension:safari:submit","Synchronize metadata and submit an existing Safari version to App Review").option("--version <version>","Marketing version to submit (defaults to package.json)").option("--api-key-id <id>","App Store Connect API key ID").option("--api-issuer-id <id>","App Store Connect API issuer ID").option("--api-key-path <path>","Path to the App Store Connect AuthKey_*.p8 file").option("--platform <platform>","Submit macos, ios, or all (defaults to config safariPlatforms)").option("--prepare-only","Synchronize the listing without submitting it for review").action(async(options)=>{const{submitSafariAppStore}=await import("@stacksjs/browser-extension"),{config,version}=await load(),result=await submitSafariAppStore(config,{version:options.version??version,keyId:options.apiKeyId,issuerId:options.apiIssuerId,keyPath:options.apiKeyPath,platforms:parseSafariPlatforms(options.platform),submit:!options.prepareOnly});log.success(`Synchronized ${result.versions.map((item)=>item.platform).join(" + ")} App Store listings`);if(result.reviewSubmissionIds.length)log.success(`Submitted ${result.reviewSubmissionIds.length} Safari version(s) to App Review`)})}
|