crossbind 2.0.0-beta.68 → 2.0.0-beta.69

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.
Files changed (57) hide show
  1. package/README.md +1 -1
  2. package/package.json +69 -59
  3. package/src/actions/addExtension.js +191 -0
  4. package/src/actions/androidGradle.js +26 -17
  5. package/src/actions/appInstaller.js +69 -153
  6. package/src/actions/appSwift.js +66 -0
  7. package/src/actions/buildApp.js +172 -74
  8. package/src/actions/buildNode.js +6 -1
  9. package/src/actions/buildWasm.js +0 -9
  10. package/src/actions/createInterface.js +17 -8
  11. package/src/actions/createLib.js +0 -4
  12. package/src/actions/extensionApi.js +298 -0
  13. package/src/actions/extensionCommands.js +41 -0
  14. package/src/actions/extensionsInApp.js +19 -0
  15. package/src/actions/gradleProject.js +196 -16
  16. package/src/actions/iosDevice.js +2 -2
  17. package/src/actions/iosSimulator.js +3 -3
  18. package/src/actions/licenses.js +83 -34
  19. package/src/actions/linuxPackages.js +48 -0
  20. package/src/actions/mavenVerification.js +37 -0
  21. package/src/actions/publish.js +220 -0
  22. package/src/actions/windowsSigning.js +1 -1
  23. package/src/actions/xcodeProject.js +49 -24
  24. package/src/actions/xcodeTargets.js +122 -0
  25. package/src/actions/xcodebuild.js +65 -0
  26. package/src/app/index.d.ts +126 -133
  27. package/src/app/index.js +126 -188
  28. package/src/assets/configure/install.sh +0 -0
  29. package/src/assets/toolchain-digests.json +20 -20
  30. package/src/bin.js +20 -28
  31. package/src/index.js +2 -1
  32. package/src/program.js +32 -3
  33. package/src/state/index.js +2 -1
  34. package/src/state/loadConfig.js +16 -5
  35. package/src/utils/appExtensions.js +761 -0
  36. package/src/utils/appFiles.js +58 -0
  37. package/src/utils/appLaunches.js +249 -0
  38. package/src/utils/appOptions.js +109 -100
  39. package/src/utils/appTargets.js +168 -0
  40. package/src/utils/bridgeTools.js +66 -0
  41. package/src/utils/configChanges.js +30 -0
  42. package/src/utils/configWriter.js +143 -26
  43. package/src/utils/extensionSteps.js +135 -0
  44. package/src/utils/openUrl.js +2 -1
  45. package/src/utils/plist.js +5 -0
  46. package/src/utils/resolveAppHost.js +7 -0
  47. package/src/utils/systemConfig.js +16 -3
  48. package/vitest.config.js +6 -1
  49. package/src/actions/appSetup.js +0 -116
  50. package/src/actions/appStoreConnect.js +0 -188
  51. package/src/actions/appUpdate.js +0 -68
  52. package/src/actions/extensions.js +0 -7
  53. package/src/actions/googlePlay.js +0 -213
  54. package/src/actions/googleSignIn.js +0 -78
  55. package/src/actions/playSetup.js +0 -19
  56. package/src/actions/publishAndroid.js +0 -102
  57. package/src/actions/publishIos.js +0 -146
@@ -3,7 +3,7 @@ import os from 'node:os';
3
3
  import { spawn, spawnSync } from 'node:child_process';
4
4
  import { EXEC_MAX_BUFFER } from './run.js';
5
5
  import { appTeam } from './buildApp.js';
6
- import { xcodebuild, xcodeTeams } from './appStoreConnect.js';
6
+ import { xcodebuild, xcodeTeams } from './xcodebuild.js';
7
7
  import state from '../state/index.js';
8
8
  import { appId } from '../utils/appOptions.js';
9
9
 
@@ -171,7 +171,7 @@ export function buildForIosDevice(project, device) {
171
171
  xcodebuild(`the build for ${device.name}`, [
172
172
  '-project', `${project}/${name}.xcodeproj`, '-scheme', name, '-configuration', 'Debug', '-sdk', 'iphoneos', '-destination', `id=${device.udid}`,
173
173
  '-allowProvisioningUpdates', '-allowProvisioningDeviceRegistration', `SYMROOT=${products}`, 'build',
174
- ], `${products}/device.log`, { team, id: appId(state.config) });
174
+ ], { log: `${products}/device.log`, facts: { team, id: appId(state.config) } });
175
175
  return `${products}/Debug-iphoneos/${name}.app`;
176
176
  }
177
177
 
@@ -43,8 +43,8 @@ export function buildForSimulator(project, configuration = 'Release') {
43
43
  return `${products}/${configuration}-iphonesimulator/${name}.app`;
44
44
  }
45
45
 
46
- // Opens a Simulator build of an iOS app, with `env` in its environment. The process returned carries the app's output
47
- // and ends with the app; close() ends the app.
46
+ // Opens a Simulator build of an iOS app, with `env` in its environment, in the Simulator of the device returned. The
47
+ // process returned carries the app's output and ends with the app; close() ends the app.
48
48
  export default function openInSimulator(app, { env = {}, show = false, stdio = 'pipe' } = {}) {
49
49
  const device = simulator();
50
50
  // Simulator.app only shows the device, which runs the app without it as well.
@@ -59,5 +59,5 @@ export default function openInSimulator(app, { env = {}, show = false, stdio = '
59
59
  childEnv[`SIMCTL_CHILD_${key}`] = value;
60
60
  });
61
61
  const child = spawn('xcrun', ['simctl', 'launch', '--console-pty', '--terminate-running-process', device, bundleId], { env: childEnv, stdio });
62
- return { child, close: () => spawnSync('xcrun', ['simctl', 'terminate', device, bundleId]) };
62
+ return { child, device, close: () => spawnSync('xcrun', ['simctl', 'terminate', device, bundleId]) };
63
63
  }
@@ -12,7 +12,11 @@ import resolveAppHostRoot, { resolveAppEmbindJsiRoot } from '../utils/resolveApp
12
12
  import {
13
13
  HERMES, HERMES_ANDROID, HERMES_SOURCE, hermesUrl,
14
14
  } from '../utils/hermes.js';
15
+ import { installersFor } from './appInstaller.js';
16
+ import { DESKTOP_PLATFORMS } from './buildApp.js';
17
+ import extensionsInApp from './extensionsInApp.js';
15
18
  import { ANDROIDX_WEBKIT } from './gradleProject.js';
19
+ import { extensionPackage, extensionPart } from '../utils/appExtensions.js';
16
20
  import { shippedBundledLicense } from '../utils/bundledLicenses.js';
17
21
  import toolchainNoticesDir from '../utils/toolchainNotices.js';
18
22
 
@@ -208,13 +212,12 @@ function nodeRuntimeRows({ isAddon }) {
208
212
  }];
209
213
  }
210
214
 
211
- // What an app's host carries beside crossbind's runtime, pinned as the third_party READMEs of @crossbind/core-app-host
215
+ // What an app's host carries beside crossbind's runtime, pinned as the READMEs beside them in @crossbind/core-app-host
212
216
  // record: React Native's Hermes, JSI's jsi.cpp from the same release, webview/webview, and on Windows the WebView2 SDK's
213
217
  // headers.
214
218
  const WEBVIEW = { version: 'cbbdee44afff22867de9fd88a9fc8350d9bdd399', sha256: '10e972a2327b5681474f4aa4499e505eaa4ff659aa995380386f08fd6fc1b763' };
215
219
  const WEBVIEW2_SDK = { version: '1.0.4258.31', sha256: '56f7f4b8bf9aee4b8efefbbdd4f67d5f74ebd1b100ed0806da71bf76af481aa9' };
216
220
  const JSI_SHA256 = 'cb6274a22abc93ecc970066539815e716d3e47716e8f93a9971cf4eb5918ffc1';
217
- const MONOCYPHER = { version: '4.0.3', sha256: '8cc9bc341a66249016db9bd70e9142d8d0aef9945973744b1ac05dbc55d8ee66' };
218
221
  // The fbjni the Hermes release for Android depends on.
219
222
  const FBJNI_VERSION = '0.7.0';
220
223
 
@@ -273,8 +276,8 @@ function androidAppRows() {
273
276
  ];
274
277
  }
275
278
 
276
- // The WebView2 SDK headers a Windows host compiles against, and the NSIS its setup program carries. webview/webview
277
- // loads the WebView2 Runtime itself, without the SDK's loader.
279
+ // The WebView2 SDK headers a Windows host compiles against. webview/webview loads the WebView2 Runtime itself, without the
280
+ // SDK's loader.
278
281
  function windowsAppRows(hostRoot) {
279
282
  const license = 'BSD-3-Clause';
280
283
  return [{
@@ -290,22 +293,73 @@ function windowsAppRows(hostRoot) {
290
293
  sourceUrl: `https://api.nuget.org/v3-flatcontainer/microsoft.web.webview2/${WEBVIEW2_SDK.version}/microsoft.web.webview2.${WEBVIEW2_SDK.version}.nupkg`,
291
294
  licenseNotes: "the WebView2 SDK's headers from the windows toolchain image, whose interface IDs the host compiles in",
292
295
  licenseText: textOf(path.join(hostRoot, 'third_party', 'webview2', 'LICENSE')),
293
- }, {
294
- npmName: '@crossbind/core-app-host',
295
- name: 'nsis',
296
- version: '3.11',
297
- nativeVersion: '3.11',
298
- sha256: null,
299
- license: 'Zlib AND MIT',
300
- licenseDeclared: 'Zlib AND MIT',
301
- licenseSelected: null,
302
- isCopyleft: false,
303
- sourceUrl: 'https://sources.debian.org/src/nsis/3.11-1/',
304
- licenseNotes: "the installer stub and plugins the app's setup program carries, from Debian's nsis in the windows toolchain image: zlib/libpng, but for the System plugin's Call.S, MIT",
305
- licenseText: textOf(path.join(hostRoot, 'third_party', 'nsis', 'LICENSE')),
306
296
  }];
307
297
  }
308
298
 
299
+ const APP_PLATFORMS = ['darwin', 'ios', 'android', 'linux', 'win32'];
300
+
301
+ // The extensions an app of this platform has, of any platform's without one: crossbind's own that bring it code, and those
302
+ // crossbind.config.js lists.
303
+ function extensionsIn(platform) {
304
+ if (!platform) return [...new Map(APP_PLATFORMS.flatMap((one) => extensionsIn(one)).map((extension) => [extension.name, extension])).values()];
305
+ const listed = (state.config.extensions ?? []).map(({ name }) => name);
306
+ const hasCode = (extension) => {
307
+ const { native, java } = extensionPart([extension], platform);
308
+ return native.length > 0 || java.length > 0;
309
+ };
310
+ return extensionsInApp(platform).filter((extension) => listed.includes(extension.name) || hasCode(extension));
311
+ }
312
+
313
+ // A row of the code of others an extension carries, by what its licenses list says of it.
314
+ const carriedRow = (entry, root, notes) => ({
315
+ npmName: null,
316
+ name: entry.name,
317
+ version: null,
318
+ nativeVersion: entry.version,
319
+ sha256: entry.sha256 ?? null,
320
+ license: entry.license,
321
+ licenseDeclared: entry.license,
322
+ licenseSelected: null,
323
+ isCopyleft: isCopyleft(entry.license),
324
+ sourceUrl: entry.source ?? null,
325
+ licenseNotes: entry.notes ?? notes,
326
+ licenseText: textOf(path.join(root, entry.file)),
327
+ });
328
+
329
+ // What the installers of a desktop app carry into what they make.
330
+ const installerRows = (platform) => (DESKTOP_PLATFORMS[platform] ? installersFor({ platform }) : [])
331
+ .flatMap(({ name, root, installer }) => (installer.licenses ?? []).map((entry) => carriedRow(entry, root, `carried by what the installer ${name} makes`)));
332
+
333
+ // What an app's extensions bring into it: each extension package's own code, and the code of others each carries, as its
334
+ // app.licenses lists it. An extension in the project's own folders is the project's code, as the app is.
335
+ function extensionRows(platform) {
336
+ return extensionsIn(platform).flatMap((extension) => {
337
+ const own = extensionPackage(extension.root, state.config.paths.base);
338
+ const owner = own?.manifest.name ?? null;
339
+ const license = own?.manifest.license ?? null;
340
+ const shipped = own && packageLicenseFile(own.dir);
341
+ const packageRows = own && !own.manifest.private ? [{
342
+ npmName: owner,
343
+ name: owner,
344
+ version: own.manifest.version ?? null,
345
+ nativeVersion: null,
346
+ license,
347
+ licenseDeclared: license,
348
+ licenseSelected: null,
349
+ isCopyleft: Boolean(license) && isCopyleft(license),
350
+ sourceUrl: own.manifest.homepage ?? null,
351
+ licenseNotes: `the app extension ${extension.name}, which the app's host compiles in`,
352
+ licenseText: shipped ? textOf(shipped) : null,
353
+ }] : [];
354
+ const carried = (extension.app.licenses ?? []).map((entry) => carriedRow(
355
+ entry,
356
+ extension.root,
357
+ `carried by the app extension ${extension.name}${owner ? ` of ${owner}` : ''}, which the app's host compiles in`,
358
+ ));
359
+ return [...packageRows, ...carried];
360
+ });
361
+ }
362
+
309
363
  function appRuntimeRows(platform) {
310
364
  const hostRoot = resolveAppHostRoot();
311
365
  const hermesLicense = textOf(path.join(hostRoot, 'third_party', 'jsi', 'LICENSE'));
@@ -347,24 +401,9 @@ function appRuntimeRows(platform) {
347
401
  nativeVersion: WEBVIEW.version,
348
402
  sha256: WEBVIEW.sha256,
349
403
  sourceUrl: 'https://github.com/webview/webview',
350
- licenseNotes: 'webview/webview, with the local patch @crossbind/core-app-host lists',
404
+ licenseNotes: 'webview/webview, with the local patches @crossbind/core-app-host lists',
351
405
  licenseText: textOf(path.join(hostRoot, 'third_party', 'webview', 'LICENSE')),
352
406
  }]),
353
- // A desktop host checks its updates with Monocypher, which the linker leaves out of one without app.updater.
354
- ...(['ios', 'android'].includes(platform) || state.config.app?.updater === undefined ? [] : [{
355
- npmName: '@crossbind/core-app-host',
356
- name: 'monocypher',
357
- version: MONOCYPHER.version,
358
- nativeVersion: MONOCYPHER.version,
359
- sha256: MONOCYPHER.sha256,
360
- license: 'BSD-2-Clause OR CC0-1.0',
361
- licenseDeclared: 'BSD-2-Clause OR CC0-1.0',
362
- licenseSelected: null,
363
- isCopyleft: false,
364
- sourceUrl: `https://monocypher.org/download/monocypher-${MONOCYPHER.version}.tar.gz`,
365
- licenseNotes: "Monocypher's Ed25519 and SHA-512, compiled into the host, which check the app's updates",
366
- licenseText: textOf(path.join(hostRoot, 'third_party', 'monocypher', 'LICENCE.md')),
367
- }]),
368
407
  ];
369
408
  }
370
409
 
@@ -484,6 +523,16 @@ export default async function collectLicenseRows(platform = null, { runtimeEnv =
484
523
  if (platform === 'wasi') rows.push(...wasiToolchainRows());
485
524
  if (['linux', 'linuxmusl', 'win32'].includes(platform)) rows.push(...addonToolchainRows(platform));
486
525
  if (runtimeEnv === 'node') rows.push(...nodeRuntimeRows({ isAddon: platform !== null }));
487
- if (runtimeEnv === 'app') rows.push(...appRuntimeRows(platform));
526
+ if (runtimeEnv === 'app') {
527
+ rows.push(...appRuntimeRows(platform));
528
+ // A library two extensions carry, or one the app has already, is listed once for each version the app ships.
529
+ const versionOf = (row) => `${row.name}@${row.nativeVersion ?? row.version}`;
530
+ const listed = new Set(rows.map(versionOf));
531
+ for (const row of [...extensionRows(platform), ...installerRows(platform)]) {
532
+ if (listed.has(versionOf(row))) continue;
533
+ listed.add(versionOf(row));
534
+ rows.push(row);
535
+ }
536
+ }
488
537
  return rows.sort((a, b) => a.name.localeCompare(b.name));
489
538
  }
@@ -0,0 +1,48 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { downloadFile, verifyIntegrity } from '../utils/downloadAndExtractFile.js';
4
+
5
+ // The Ubuntu 22.04 packages a Linux extension lists beside crossbind's sysroot: each downloaded once into the build's
6
+ // folder from Launchpad, as the sysroot's own are, checked against its SHA-256 and unpacked into a sysroot of the
7
+ // extension's own for the target, whose headers its sources include and whose libraries the host links against. Its
8
+ // users' systems load the libraries at run time, as the app's Debian package's Depends asks for them.
9
+
10
+ const DEBIAN_ARCH = { x64: 'amd64', arm64: 'arm64' };
11
+ const MULTIARCH = { x64: 'x86_64-linux-gnu', arm64: 'aarch64-linux-gnu' };
12
+
13
+ export const packageUrl = (file) => `https://launchpad.net/ubuntu/+archive/primary/+files/${file}`;
14
+
15
+ // The packages of the target's architecture among the lines an extension lists.
16
+ export function packagesFor(target, lines) {
17
+ return lines.map((line) => line.split(' ')).filter(([arch]) => arch === DEBIAN_ARCH[target.arch]).map(([, file, sha256]) => ({ file, sha256 }));
18
+ }
19
+
20
+ // What a sysroot gives the target: the folders its sources include, usr/include and each folder in it, as a library's
21
+ // headers sit in one of its own, and the folder the host links from.
22
+ export function sysrootDirs(target, root) {
23
+ const include = path.join(root, 'usr', 'include');
24
+ const nested = fs.existsSync(include)
25
+ ? fs.readdirSync(include, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => path.join(include, entry.name))
26
+ : [];
27
+ return { includes: [include, ...nested], libDir: path.join(root, 'usr', 'lib', MULTIARCH[target.arch]) };
28
+ }
29
+
30
+ // Unpacks each extension's packages for the target, with unpack(deb, folder), which crossbind's Linux image runs.
31
+ export async function unpackPackages(target, compiled, { cache, workDir, unpack }) {
32
+ const sysroots = [];
33
+ for (const { name, packages } of compiled.filter((part) => part.packages.length > 0)) {
34
+ const own = packagesFor(target, packages);
35
+ if (own.length === 0) throw new Error(`crossbind: the extension ${name} lists Linux packages, but none of ${DEBIAN_ARCH[target.arch]}, which an ${target.arch} app takes.`);
36
+ const root = path.join(workDir, 'sysroot', name);
37
+ fs.rmSync(root, { recursive: true, force: true });
38
+ fs.mkdirSync(root, { recursive: true });
39
+ for (const { file, sha256 } of own) {
40
+ const url = packageUrl(file);
41
+ const deb = await downloadFile(url, cache);
42
+ verifyIntegrity(deb, url, sha256);
43
+ unpack(deb, root);
44
+ }
45
+ sysroots.push({ name, ...sysrootDirs(target, root) });
46
+ }
47
+ return sysroots;
48
+ }
@@ -0,0 +1,37 @@
1
+ // The verification metadata of an Android app whose extensions ask Maven for libraries: the android-app image's, which
2
+ // lists all crossbind's own project resolves, with each extension's components, as Gradle's
3
+ // --write-verification-metadata lists them. Gradle then checks every file it resolves, from the image's cache or online.
4
+
5
+ const COMPONENT = /<component group="([^"]+)" name="([^"]+)" version="([^"]+)"[^>]*>([\s\S]*?)<\/component>/g;
6
+ const ARTIFACT = /<artifact name="([^"]+)">[\s\S]*?<\/artifact>/g;
7
+ // What would leave a file unchecked, which an extension's metadata may not add to the app's.
8
+ const TRUST = /<(trusted-artifacts|trust|ignored-keys|trusted-keys)\b/;
9
+
10
+ export function mergedVerification(base, fragments) {
11
+ const components = new Map();
12
+ const add = (xml, owner) => {
13
+ for (const [, group, name, version, body] of xml.matchAll(COMPONENT)) {
14
+ const key = `${group}:${name}:${version}`;
15
+ const artifacts = components.get(key) ?? new Map();
16
+ for (const [block, artifact] of body.matchAll(ARTIFACT)) {
17
+ const known = artifacts.get(artifact);
18
+ if (known && known.block !== block) throw new Error(`crossbind: ${owner} gives ${artifact} of ${key} other checksums than ${known.owner} does.`);
19
+ if (!known) artifacts.set(artifact, { block, owner });
20
+ }
21
+ components.set(key, artifacts);
22
+ }
23
+ };
24
+ add(base, "the android-app image's verification metadata");
25
+ for (const { name, xml } of fragments) {
26
+ const owner = `the extension ${name}`;
27
+ if (TRUST.test(xml)) throw new Error(`crossbind: ${owner}'s verification metadata trusts files Gradle would then leave unchecked, which no extension's may.`);
28
+ if ([...xml.matchAll(COMPONENT)].length === 0) throw new Error(`crossbind: ${owner}'s verification metadata lists no component, as Gradle's --write-verification-metadata sha256 writes them.`);
29
+ add(xml, owner);
30
+ }
31
+ const listed = [...components.entries()].sort(([one], [other]) => (one < other ? -1 : 1)).map(([key, artifacts]) => {
32
+ const [group, name, version] = key.split(':');
33
+ const blocks = [...artifacts.values()].map(({ block }) => ` ${block}\n`).join('');
34
+ return ` <component group="${group}" name="${name}" version="${version}">\n${blocks} </component>\n`;
35
+ });
36
+ return `${base.slice(0, base.indexOf('<components>'))}<components>\n${listed.join('')} </components>\n</verification-metadata>\n`;
37
+ }
@@ -0,0 +1,220 @@
1
+ import state from '../state/index.js';
2
+ import loadConfig from '../state/loadConfig.js';
3
+ import {
4
+ DESKTOP_PLATFORMS, MAX_VERSION_CODE, appOutputOf, clearAppOutputs,
5
+ } from './buildApp.js';
6
+ import extensionApi from './extensionApi.js';
7
+ import {
8
+ PUBLISH_PLATFORMS, extensionStep, isJsonValue, ownExtensions, withListed, withoutNamed,
9
+ } from '../utils/appExtensions.js';
10
+ import { CONFIG_CHANGES } from '../utils/configChanges.js';
11
+ import { hasTerminal } from '../utils/ask.js';
12
+ import resolveAppHostRoot from '../utils/resolveAppHost.js';
13
+
14
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
15
+ const possessive = (name) => (name.endsWith('s') ? `${name}'` : `${name}'s`);
16
+ const listed = (names, word = 'or') => (names.length > 1 ? `${names.slice(0, -1).join(', ')} ${word} ${names.at(-1)}` : names[0]);
17
+ const kebab = (name) => name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
18
+ const camel = (name) => name.replace(/-([a-z0-9])/g, (dash, letter) => letter.toUpperCase());
19
+ const storeName = ({ name, publisher }) => publisher.store ?? name;
20
+ // How many builds a publish makes at most, as its first publisher may ask for another.
21
+ const MAX_BUILDS = 4;
22
+
23
+ // A step of the publisher's, which its publisher may lack.
24
+ const step = ({ name, publisher }, which, ...args) => extensionStep(name, which, () => publisher[which]?.(...args));
25
+
26
+ // crossbind's own publishers, which the host brings as it brings its other extensions.
27
+ export const builtInPublishers = () => withoutNamed(ownExtensions(resolveAppHostRoot()), state.config?.app).filter(({ publisher }) => publisher);
28
+
29
+ // The publishers an app has: crossbind's own, each in the place of the app's extension of its name that has a
30
+ // publisher, then those of the app's other extensions.
31
+ export const publishersOf = (extensions) => withListed(builtInPublishers(), extensions.filter((extension) => extension.publisher));
32
+
33
+ // The store the command names first, before the options, else the last publisher that says it is the one of the platform
34
+ // -p, or CROSSBIND_APP_PLATFORM, names, and the arguments after it. A store named after the options, of those crossbind
35
+ // knows, is refused, as Vite would take it.
36
+ export function storeOf(args, platform, envPlatform, publishers = []) {
37
+ const [first, ...rest] = args;
38
+ const named = first !== undefined && !first.startsWith('-') ? first : null;
39
+ const late = named ? null : args.find((arg) => publishers.some(({ name }) => name === arg));
40
+ if (late) throw new Error(`crossbind: crossbind app publish takes the store first, before the options: crossbind app publish ${late} ${args.filter((arg) => arg !== late).join(' ')}.`);
41
+ const where = platform ?? envPlatform;
42
+ const usual = publishers.findLast(({ publisher }) => publisher.isDefault && publisher.platforms.includes(where));
43
+ return { store: named ?? usual?.name ?? null, rest: named ? rest : args };
44
+ }
45
+
46
+ // The publisher of a store, after those it comes after, first to last.
47
+ export function publishChain(publishers, store) {
48
+ if (!publishers.some(({ name }) => name === store)) {
49
+ throw new Error(`crossbind: crossbind app publish takes the store it sends the app to first: ${listed(publishers.map(({ name }) => name))}${store ? `, not ${store}` : ''}.`);
50
+ }
51
+ const chain = [];
52
+ for (let current = store; current;) {
53
+ const extension = publishers.find(({ name }) => name === current);
54
+ if (!extension) throw new Error(`crossbind: ${chain[0].name} comes after ${current}, which no publisher is named.`);
55
+ if (chain.includes(extension)) throw new Error(`crossbind: ${listed([extension, ...chain].map(({ name }) => name), 'and')} come after one another in a circle.`);
56
+ chain.unshift(extension);
57
+ current = extension.publisher.after;
58
+ }
59
+ return chain;
60
+ }
61
+
62
+ // The platform a publish builds for: the one -p names, which every publisher of it takes, else the only one they take,
63
+ // else CROSSBIND_APP_PLATFORM's, else this machine's desktop one.
64
+ export function publishPlatform(chain, platform, envPlatform, host = process.platform) {
65
+ const store = chain.at(-1).name;
66
+ const platforms = chain.reduce((taken, { publisher }) => taken.filter((one) => publisher.platforms.includes(one)), PUBLISH_PLATFORMS);
67
+ if (platforms.length === 0) throw new Error(`crossbind: ${store} comes after ${chain[0].name}, which takes apps of none of its platforms.`);
68
+ const takes = `${store} takes ${listed(platforms)} apps`;
69
+ if (platform) {
70
+ if (!platforms.includes(platform)) throw new Error(`crossbind: ${takes}, not ${possessive(platform)}, which -p names.`);
71
+ return platform;
72
+ }
73
+ if (platforms.length === 1) return platforms[0];
74
+ if (envPlatform) {
75
+ if (!platforms.includes(envPlatform)) throw new Error(`crossbind: ${takes}, not ${possessive(envPlatform)}, which CROSSBIND_APP_PLATFORM names.`);
76
+ return envPlatform;
77
+ }
78
+ if (platforms.includes(DESKTOP_PLATFORMS[host])) return DESKTOP_PLATFORMS[host];
79
+ throw new Error(`crossbind: ${takes}: name one with -p.`);
80
+ }
81
+
82
+ // The options of each publisher of a publish, as the command line gives them, and the arguments Vite takes. An option
83
+ // that another publisher takes is refused, naming whose it is; any other goes on to Vite.
84
+ export function publishOptions(chain, args, publishers) {
85
+ const kinds = new Map();
86
+ chain.forEach(({ name, publisher }) => Object.entries(publisher.options).forEach(([option, { value }]) => {
87
+ const first = kinds.get(option);
88
+ if (first && (first.value === null) !== (value === null)) {
89
+ throw new Error(`crossbind: ${first.name} and ${name} both take --${kebab(option)}, one as a flag and one with a value: one of them names it otherwise.`);
90
+ }
91
+ if (!first) kinds.set(option, { name, value });
92
+ }));
93
+ const given = new Map(chain.map(({ name }) => [name, new Map()]));
94
+ const foreign = new Map();
95
+ const viteArgs = [];
96
+ for (let index = 0; index < args.length; index += 1) {
97
+ const match = /^--([a-z][a-z0-9-]*)(?:=(.*))?$/s.exec(args[index]);
98
+ const option = match && camel(match[1]);
99
+ const owners = match ? chain.filter(({ publisher }) => Object.hasOwn(publisher.options, option)) : [];
100
+ if (owners.length === 0) {
101
+ const other = match && publishers.find(({ publisher }) => Object.hasOwn(publisher.options, option));
102
+ if (other) foreign.set(other.name, [...(foreign.get(other.name) ?? []), `--${match[1]}`]);
103
+ else viteArgs.push(args[index]);
104
+ continue;
105
+ }
106
+ const { value: placeholder } = owners.at(-1).publisher.options[option];
107
+ let value = true;
108
+ if (placeholder !== null && match[2] === undefined) {
109
+ value = args[index + 1];
110
+ index += 1;
111
+ if (value === undefined || value.startsWith('-')) throw new Error(`crossbind: --${match[1]} takes a value, as ${placeholder}.`);
112
+ } else if (placeholder !== null) {
113
+ value = match[2];
114
+ } else if (match[2] !== undefined) {
115
+ throw new Error(`crossbind: --${match[1]} takes no value.`);
116
+ }
117
+ owners.forEach(({ name }) => given.get(name).set(option, value));
118
+ }
119
+ if (foreign.size > 0) {
120
+ const whose = [...foreign].map(([owner, names]) => `${listed(names, 'and')} ${names.length > 1 ? 'are' : 'is'} ${possessive(owner)}`);
121
+ throw new Error(`crossbind: ${listed(whose, 'and')}, which ${chain.at(-1).name} takes none of.`);
122
+ }
123
+ const options = Object.fromEntries(chain.map(({ name, publisher }) => [name, Object.fromEntries(Object.entries(publisher.options)
124
+ .map(([option, { value, default: fallback }]) => [option, given.get(name).get(option) ?? (value === null ? false : fallback)]))]));
125
+ return { options, viteArgs };
126
+ }
127
+
128
+ // The help of the stores crossbind publishes to itself, with the options each takes after its name.
129
+ export function publishHelp(publishers = builtInPublishers()) {
130
+ const signature = (option, { value }) => `--${kebab(option)}${value ? ` ${value}` : ''}`;
131
+ const width = Math.max(...publishers.flatMap(({ publisher }) => Object.entries(publisher.options).map(([option, declared]) => signature(option, declared).length)));
132
+ return [
133
+ '',
134
+ 'Stores, each with the options it takes after its name:',
135
+ ...publishers.flatMap(({ name, publisher }) => [
136
+ ` ${name}: ${storeName({ name, publisher })}, for ${listed(publisher.platforms)} apps${publisher.isDefault ? ', which goes first for them' : ''}`,
137
+ ...Object.entries(publisher.options).map(([option, declared]) => ` ${signature(option, declared).padEnd(width)} ${declared.help}${declared.default ? ` (default: ${declared.default})` : ''}`),
138
+ ]),
139
+ "An app's extensions add stores of their own, each with the options its package gives.",
140
+ ].join('\n');
141
+ }
142
+
143
+ // The changes the first publisher makes to the config for its build: a JSON merge patch of crossbind.config.js's
144
+ // export.
145
+ async function buildChanges(extension, api) {
146
+ const { name } = extension;
147
+ const changes = await step(extension, 'build', api);
148
+ if (changes === null || changes === undefined) return null;
149
+ if (!isObject(changes) || !isJsonValue(changes)) {
150
+ throw new Error(`crossbind: ${name}'s build gives no changes to crossbind.config.js, which are an object of JSON values merged into its export, where null removes a key.`);
151
+ }
152
+ return changes;
153
+ }
154
+
155
+ // Hands the build's output to the uploads of the publishers after the first, each with what the one before it put out.
156
+ async function uploadAfter(chain, apis, sessions, output, firstPublished) {
157
+ let published = firstPublished;
158
+ for (const [index, extension] of chain.entries()) {
159
+ const { name } = extension;
160
+ if (index > 0) {
161
+ const answer = await step(extension, 'upload', apis[index], sessions[index], output, published);
162
+ if (answer?.isRebuildAsked) throw new Error(`crossbind: ${name} asks for another build, which ${chain[0].name}, whose build it publishes from, alone can ask for.`);
163
+ published = answer?.published;
164
+ }
165
+ }
166
+ }
167
+
168
+ // Sends an app to a store through its publisher, after those it comes after. All they need is checked before the build,
169
+ // which takes minutes: on a terminal they ask for what is missing and set it up; without one, as in CI, every missing
170
+ // piece is named at once. crossbind then builds the app once, with the first publisher's changes to the config, build
171
+ // number and environment, and hands its output to each one's upload in turn. The first may ask for another build, as
172
+ // an iOS app's does once its user has given Apple's reasons for an API it calls.
173
+ export default async function publish(args, { platform, envPlatform, runVite }) {
174
+ const publishers = publishersOf(state.config.extensions);
175
+ const { store, rest } = storeOf(args, platform, envPlatform, publishers);
176
+ const chain = publishChain(publishers, store);
177
+ const own = builtInPublishers().map(({ name }) => name);
178
+ chain.filter((extension) => own.includes(extension.name) && state.config.extensions.includes(extension))
179
+ .forEach(({ name, root }) => console.log(`crossbind: the extension ${name} of ${root} replaces crossbind's own publisher of its name.`));
180
+ const target = publishPlatform(chain, platform, envPlatform);
181
+ const { options, viteArgs } = publishOptions(chain, rest, publishers);
182
+ const run = { buildNumber: undefined };
183
+ const isGuided = hasTerminal();
184
+ const apis = chain.map(({ name }) => extensionApi(name, {
185
+ platform: target, options: options[name], isGuided, run,
186
+ }));
187
+ const [first] = chain;
188
+ const changes = await buildChanges(first, apis[0]);
189
+ if (changes) state.config = await loadConfig(undefined, undefined, { changes });
190
+ const checked = [];
191
+ for (const [index, extension] of chain.entries()) checked.push((await step(extension, 'check', apis[index])) ?? {});
192
+ const missing = checked.flatMap((result) => result.missing ?? []);
193
+ if (missing.length > 0) throw new Error(missing.join('\n'));
194
+ const sessions = checked.map(({ session }) => session);
195
+ run.buildNumber = (await step(first, 'buildNumber', apis[0], sessions[0])) ?? null;
196
+ if (run.buildNumber !== null && !(Number.isInteger(run.buildNumber) && run.buildNumber >= 1 && run.buildNumber <= MAX_VERSION_CODE)) {
197
+ throw new Error(`crossbind: ${first.name}'s buildNumber gives ${JSON.stringify(run.buildNumber)}, which is no build number: a whole number from 1 to ${MAX_VERSION_CODE}, as both stores take, or null for the app's own.`);
198
+ }
199
+ for (let builds = 1; ; builds += 1) {
200
+ clearAppOutputs();
201
+ const buildEnv = await step(first, 'buildEnv', apis[0], sessions[0]);
202
+ const exitCode = await runVite(['build', ...viteArgs], {
203
+ ...process.env,
204
+ ...(changes ? { [CONFIG_CHANGES]: JSON.stringify(changes) } : {}),
205
+ ...buildEnv,
206
+ CROSSBIND_APP_PLATFORM: target,
207
+ ...(run.buildNumber === null ? {} : { CROSSBIND_APP_BUILD_NUMBER: String(run.buildNumber) }),
208
+ });
209
+ if (exitCode !== 0) {
210
+ throw Object.assign(new Error(`crossbind: the build ended with exit code ${exitCode}, so nothing went to ${storeName(first)}.`), { exitCode });
211
+ }
212
+ const output = appOutputOf(target);
213
+ const answer = await step(first, 'upload', apis[0], sessions[0], output);
214
+ if (!answer?.isRebuildAsked) {
215
+ await uploadAfter(chain, apis, sessions, output, answer?.published);
216
+ return;
217
+ }
218
+ if (builds === MAX_BUILDS) throw new Error(`crossbind: ${first.name} asks for another build after ${MAX_BUILDS}, as many as a publish makes.`);
219
+ }
220
+ }
@@ -49,7 +49,7 @@ export function windowsSigning(env = process.env) {
49
49
  return null;
50
50
  }
51
51
  if (TOKENS.includes(storetype)) {
52
- throw new Error(`crossbind: ${WINDOWS_SIGNING.storetype} ${storetype} is a hardware token, which crossbind's windows container does not reach. Sign with a cloud's key service, as Azure Trusted Signing (TRUSTEDSIGNING) or Azure Key Vault (AZUREKEYVAULT), or with a PKCS#12 file.`);
52
+ throw new Error(`crossbind: ${WINDOWS_SIGNING.storetype} ${storetype} is a hardware token, which crossbind's windows container does not reach. Sign with a cloud's key service, as Azure Artifact Signing (TRUSTEDSIGNING) or Azure Key Vault (AZUREKEYVAULT), or with a PKCS#12 file.`);
53
53
  }
54
54
  if (![...FILE_STORES, ...CLOUDS].includes(storetype)) {
55
55
  throw new Error(`crossbind: ${WINDOWS_SIGNING.storetype} ${storetype} is no store type jsign takes: ${[...FILE_STORES, ...CLOUDS].join(', ')}.`);