@mittwald/cli 1.0.0-alpha.39 → 1.0.0-alpha.40

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 (40) hide show
  1. package/README.md +56 -26
  2. package/dist/BaseCommand.d.ts +0 -4
  3. package/dist/BaseCommand.js +3 -30
  4. package/dist/commands/app/install/nextcloud.js +1 -1
  5. package/dist/commands/ddev/init.d.ts +12 -1
  6. package/dist/commands/ddev/init.js +64 -25
  7. package/dist/commands/ddev/render-config.d.ts +2 -0
  8. package/dist/commands/ddev/render-config.js +4 -1
  9. package/dist/commands/login/reset.js +11 -8
  10. package/dist/commands/login/token.js +7 -4
  11. package/dist/lib/api_consistency.js +8 -2
  12. package/dist/lib/api_retry.js +16 -2
  13. package/dist/lib/auth/token.d.ts +13 -0
  14. package/dist/lib/auth/token.js +44 -0
  15. package/dist/lib/ddev/config_builder.d.ts +3 -1
  16. package/dist/lib/ddev/config_builder.js +27 -12
  17. package/dist/lib/ddev/flags.d.ts +9 -0
  18. package/dist/lib/ddev/flags.js +17 -0
  19. package/dist/lib/ddev/init_assert.d.ts +3 -0
  20. package/dist/lib/ddev/init_assert.js +22 -0
  21. package/dist/lib/ddev/init_database.d.ts +19 -0
  22. package/dist/lib/ddev/init_database.js +59 -0
  23. package/dist/lib/error/InteractiveInputRequiredError.d.ts +7 -0
  24. package/dist/lib/error/InteractiveInputRequiredError.js +11 -0
  25. package/dist/rendering/process/components/InteractiveInputDisabled.d.ts +1 -0
  26. package/dist/rendering/process/components/InteractiveInputDisabled.js +3 -0
  27. package/dist/rendering/process/components/ProcessInput.js +1 -1
  28. package/dist/rendering/process/components/ProcessSelect.d.ts +6 -0
  29. package/dist/rendering/process/components/ProcessSelect.js +61 -0
  30. package/dist/rendering/process/components/ProcessSelectStateSummary.d.ts +4 -0
  31. package/dist/rendering/process/components/ProcessSelectStateSummary.js +10 -0
  32. package/dist/rendering/process/components/ProcessStateIcon.js +3 -1
  33. package/dist/rendering/process/components/ProcessStateSummary.js +4 -0
  34. package/dist/rendering/process/process.d.ts +14 -1
  35. package/dist/rendering/process/process_fancy.d.ts +4 -0
  36. package/dist/rendering/process/process_fancy.js +30 -0
  37. package/dist/rendering/process/process_quiet.d.ts +1 -0
  38. package/dist/rendering/process/process_quiet.js +3 -0
  39. package/dist/rendering/react/components/ErrorBox.js +6 -3
  40. package/package.json +1 -1
package/README.md CHANGED
@@ -1010,7 +1010,7 @@ FLAG DESCRIPTIONS
1010
1010
 
1011
1011
  ## `mw app install nextcloud`
1012
1012
 
1013
- Creates new Shopware 6 installation.
1013
+ Creates new Nextcloud installation.
1014
1014
 
1015
1015
  ```
1016
1016
  USAGE
@@ -1021,17 +1021,17 @@ FLAGS
1021
1021
  -p, --project-id=<value> ID or short ID of a project; this flag is optional if a default project is set in the
1022
1022
  context
1023
1023
  -q, --quiet suppress process output and only display a machine-readable summary.
1024
- -w, --wait wait for your Shopware 6 to be ready.
1024
+ -w, --wait wait for your Nextcloud to be ready.
1025
1025
  --admin-email=<value> email address of your administrator user.
1026
1026
  --admin-pass=<value> password of your administrator user.
1027
1027
  --admin-user=<value> Username for your administrator user.
1028
- --host=<value> host to initially configure your Shopware 6 installation with; needs to be created
1028
+ --host=<value> host to initially configure your Nextcloud installation with; needs to be created
1029
1029
  separately.
1030
- --site-title=<value> site title for your Shopware 6 installation.
1031
- --version=<value> (required) [default: latest] version of Shopware 6 to be installed.
1030
+ --site-title=<value> site title for your Nextcloud installation.
1031
+ --version=<value> (required) [default: latest] version of Nextcloud to be installed.
1032
1032
 
1033
1033
  DESCRIPTION
1034
- Creates new Shopware 6 installation.
1034
+ Creates new Nextcloud installation.
1035
1035
 
1036
1036
  FLAG DESCRIPTIONS
1037
1037
  -p, --project-id=<value> ID or short ID of a project; this flag is optional if a default project is set in the context
@@ -1046,40 +1046,40 @@ FLAG DESCRIPTIONS
1046
1046
 
1047
1047
  --admin-email=<value> email address of your administrator user.
1048
1048
 
1049
- email address that will be used for the first administrator user that is created during the Shopware 6 installation.
1049
+ email address that will be used for the first administrator user that is created during the Nextcloud installation.
1050
1050
  If unspecified, email address of your mStudio account will be used. This email address can be changed after the
1051
1051
  installation is finished.
1052
1052
 
1053
1053
  --admin-pass=<value> password of your administrator user.
1054
1054
 
1055
- The password that will be used for the first administrator user that is created during the Shopware 6 installation.
1055
+ The password that will be used for the first administrator user that is created during the Nextcloud installation.
1056
1056
  If unspecified, a random secure password will be generated and printed to stdout. This password can be changed after
1057
1057
  the installation is finished
1058
1058
 
1059
1059
  --admin-user=<value> Username for your administrator user.
1060
1060
 
1061
- Username of the first administrator user which will be created during the Shopware 6 installation.
1061
+ Username of the first administrator user which will be created during the Nextcloud installation.
1062
1062
  If unspecified, an adequate username will be generated.
1063
1063
  After the installation is finished, the username can be changed and additional administrator users can be created.
1064
1064
 
1065
- --host=<value> host to initially configure your Shopware 6 installation with; needs to be created separately.
1065
+ --host=<value> host to initially configure your Nextcloud installation with; needs to be created separately.
1066
1066
 
1067
- Specify a host which will be used during the installation and as an initial host for the Shopware 6 configuration.
1067
+ Specify a host which will be used during the installation and as an initial host for the Nextcloud configuration.
1068
1068
  If unspecified, the default host for the given project will be used.
1069
- This does not change the target of the used host and can be changed later by configuring the host and your Shopware
1070
- 6 installation.
1069
+ This does not change the target of the used host and can be changed later by configuring the host and your Nextcloud
1070
+ installation.
1071
1071
 
1072
- --site-title=<value> site title for your Shopware 6 installation.
1072
+ --site-title=<value> site title for your Nextcloud installation.
1073
1073
 
1074
- The site title for this Shopware 6 installation. It is also the title shown in the app overview in the mStudio and
1074
+ The site title for this Nextcloud installation. It is also the title shown in the app overview in the mStudio and
1075
1075
  the CLI.
1076
1076
  If unspecified, the application name and the given project ID will be used. The title can be changed after the
1077
1077
  installation is finished
1078
1078
 
1079
- --version=<value> version of Shopware 6 to be installed.
1079
+ --version=<value> version of Nextcloud to be installed.
1080
1080
 
1081
- Specify the version in which your Shopware 6 will be installed.
1082
- If unspecified, the Shopware 6 will be installed in the latest available version.
1081
+ Specify the version in which your Nextcloud will be installed.
1082
+ If unspecified, the Nextcloud will be installed in the latest available version.
1083
1083
  ```
1084
1084
 
1085
1085
  ## `mw app install shopware5`
@@ -1588,7 +1588,7 @@ EXAMPLES
1588
1588
  $ mw autocomplete --refresh-cache
1589
1589
  ```
1590
1590
 
1591
- _See code: [@oclif/plugin-autocomplete](https://github.com/oclif/plugin-autocomplete/blob/v3.0.11/src/commands/autocomplete/index.ts)_
1591
+ _See code: [@oclif/plugin-autocomplete](https://github.com/oclif/plugin-autocomplete/blob/v3.0.13/src/commands/autocomplete/index.ts)_
1592
1592
 
1593
1593
  ## `mw backup create`
1594
1594
 
@@ -2727,8 +2727,8 @@ Initialize a new ddev project in the current directory.
2727
2727
 
2728
2728
  ```
2729
2729
  USAGE
2730
- $ mw ddev init [INSTALLATION-ID] [-q] [--override-type <value>] [--project-name <value>]
2731
- [--override-mittwald-plugin <value>]
2730
+ $ mw ddev init [INSTALLATION-ID] [-q] [--override-type <value>] [--without-database | --database-id <value>]
2731
+ [--project-name <value>] [--override-mittwald-plugin <value>]
2732
2732
 
2733
2733
  ARGUMENTS
2734
2734
  INSTALLATION-ID ID or short ID of an app installation; this argument is optional if a default app installation is set
@@ -2736,8 +2736,10 @@ ARGUMENTS
2736
2736
 
2737
2737
  FLAGS
2738
2738
  -q, --quiet suppress process output and only display a machine-readable summary.
2739
+ --database-id=<value> ID of the application database
2739
2740
  --override-type=<value> [default: auto] Override the type of the generated DDEV configuration
2740
2741
  --project-name=<value> DDEV project name
2742
+ --without-database Create a DDEV project without a database
2741
2743
 
2742
2744
  DEVELOPMENT FLAGS
2743
2745
  --override-mittwald-plugin=<value> [default: mittwald/ddev] override the mittwald plugin
@@ -2764,6 +2766,14 @@ FLAG DESCRIPTIONS
2764
2766
  This flag controls if you want to see the process output or only a summary. When using mw non-interactively (e.g. in
2765
2767
  scripts), you can use this flag to easily get the IDs of created resources for further processing.
2766
2768
 
2769
+ --database-id=<value> ID of the application database
2770
+
2771
+ The ID of the database to use for the DDEV project; if set to 'auto', the command will use the database linked to
2772
+ the app installation.
2773
+
2774
+ Setting a database ID (either automatically or manually) is required. To create a DDEV project without a database,
2775
+ set the --without-database flag.
2776
+
2767
2777
  --override-mittwald-plugin=<value> override the mittwald plugin
2768
2778
 
2769
2779
  This flag allows you to override the mittwald plugin that should be installed by default; this is useful for testing
@@ -2779,6 +2789,11 @@ FLAG DESCRIPTIONS
2779
2789
  --project-name=<value> DDEV project name
2780
2790
 
2781
2791
  The name of the DDEV project
2792
+
2793
+ --without-database Create a DDEV project without a database
2794
+
2795
+ Use this flag to create a DDEV project without a database; this is useful for projects that do not require a
2796
+ database.
2782
2797
  ```
2783
2798
 
2784
2799
  ## `mw ddev render-config [INSTALLATION-ID]`
@@ -2787,14 +2802,16 @@ Generate a DDEV configuration YAML file for the current app.
2787
2802
 
2788
2803
  ```
2789
2804
  USAGE
2790
- $ mw ddev render-config [INSTALLATION-ID] [--override-type <value>]
2805
+ $ mw ddev render-config [INSTALLATION-ID] [--override-type <value>] [--without-database | --database-id <value>]
2791
2806
 
2792
2807
  ARGUMENTS
2793
2808
  INSTALLATION-ID ID or short ID of an app installation; this argument is optional if a default app installation is set
2794
2809
  in the context
2795
2810
 
2796
2811
  FLAGS
2812
+ --database-id=<value> ID of the application database
2797
2813
  --override-type=<value> [default: auto] Override the type of the generated DDEV configuration
2814
+ --without-database Create a DDEV project without a database
2798
2815
 
2799
2816
  DESCRIPTION
2800
2817
  Generate a DDEV configuration YAML file for the current app.
@@ -2802,12 +2819,25 @@ DESCRIPTION
2802
2819
  This command initializes a new ddev configuration in the current directory.
2803
2820
 
2804
2821
  FLAG DESCRIPTIONS
2822
+ --database-id=<value> ID of the application database
2823
+
2824
+ The ID of the database to use for the DDEV project; if set to 'auto', the command will use the database linked to
2825
+ the app installation.
2826
+
2827
+ Setting a database ID (either automatically or manually) is required. To create a DDEV project without a database,
2828
+ set the --without-database flag.
2829
+
2805
2830
  --override-type=<value> Override the type of the generated DDEV configuration
2806
2831
 
2807
2832
  The type of the generated DDEV configuration; this can be any of the documented DDEV project types, or 'auto' (which
2808
2833
  is also the default) for automatic discovery.
2809
2834
 
2810
2835
  See https://ddev.readthedocs.io/en/latest/users/configuration/config/#type for more information
2836
+
2837
+ --without-database Create a DDEV project without a database
2838
+
2839
+ Use this flag to create a DDEV project without a database; this is useful for projects that do not require a
2840
+ database.
2811
2841
  ```
2812
2842
 
2813
2843
  ## `mw domain dnszone get DNSZONE-ID`
@@ -3112,10 +3142,10 @@ Display help for mw.
3112
3142
 
3113
3143
  ```
3114
3144
  USAGE
3115
- $ mw help [COMMAND] [-n]
3145
+ $ mw help [COMMAND...] [-n]
3116
3146
 
3117
3147
  ARGUMENTS
3118
- COMMAND Command to show help for.
3148
+ COMMAND... Command to show help for.
3119
3149
 
3120
3150
  FLAGS
3121
3151
  -n, --nested-commands Include all nested commands in the output.
@@ -3124,7 +3154,7 @@ DESCRIPTION
3124
3154
  Display help for mw.
3125
3155
  ```
3126
3156
 
3127
- _See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v6.0.16/src/commands/help.ts)_
3157
+ _See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v6.0.18/src/commands/help.ts)_
3128
3158
 
3129
3159
  ## `mw login reset`
3130
3160
 
@@ -4518,7 +4548,7 @@ EXAMPLES
4518
4548
  $ mw update --available
4519
4549
  ```
4520
4550
 
4521
- _See code: [@oclif/plugin-update](https://github.com/oclif/plugin-update/blob/v4.1.16/src/commands/update.ts)_
4551
+ _See code: [@oclif/plugin-update](https://github.com/oclif/plugin-update/blob/v4.2.0/src/commands/update.ts)_
4522
4552
 
4523
4553
  ## `mw user api-token create`
4524
4554
 
@@ -4,8 +4,4 @@ export declare abstract class BaseCommand extends Command {
4
4
  protected authenticationRequired: boolean;
5
5
  protected apiClient: MittwaldAPIV2Client;
6
6
  init(): Promise<void>;
7
- protected getTokenFilename(): string;
8
- private readApiToken;
9
- private readApiTokenFromEnvironment;
10
- private readApiTokenFromConfig;
11
7
  }
@@ -1,18 +1,17 @@
1
1
  import { Command } from "@oclif/core";
2
- import * as fs from "fs/promises";
3
- import * as path from "path";
4
2
  import { MittwaldAPIV2Client } from "@mittwald/api-client";
5
3
  import { configureAxiosRetry } from "./lib/api_retry.js";
6
4
  import { configureConsistencyHandling } from "./lib/api_consistency.js";
5
+ import { getTokenFilename, readApiToken } from "./lib/auth/token.js";
7
6
  export class BaseCommand extends Command {
8
7
  authenticationRequired = true;
9
8
  apiClient = MittwaldAPIV2Client.newUnauthenticated();
10
9
  async init() {
11
10
  await super.init();
12
11
  if (this.authenticationRequired) {
13
- const token = await this.readApiToken();
12
+ const token = await readApiToken(this.config);
14
13
  if (token === undefined) {
15
- throw new Error(`Could not get token from either config file (${this.getTokenFilename()}) or environment`);
14
+ throw new Error(`Could not get token from either config file (${getTokenFilename(this.config)}) or environment`);
16
15
  }
17
16
  this.apiClient = MittwaldAPIV2Client.newWithToken(token);
18
17
  this.apiClient.axios.defaults.headers["User-Agent"] =
@@ -21,30 +20,4 @@ export class BaseCommand extends Command {
21
20
  configureConsistencyHandling(this.apiClient.axios);
22
21
  }
23
22
  }
24
- getTokenFilename() {
25
- return path.join(this.config.configDir, "token");
26
- }
27
- async readApiToken() {
28
- return (this.readApiTokenFromEnvironment() ??
29
- (await this.readApiTokenFromConfig()));
30
- }
31
- readApiTokenFromEnvironment() {
32
- const token = process.env.MITTWALD_API_TOKEN;
33
- if (token === undefined) {
34
- return undefined;
35
- }
36
- return token.trim();
37
- }
38
- async readApiTokenFromConfig() {
39
- try {
40
- const tokenFileContents = await fs.readFile(this.getTokenFilename(), "utf-8");
41
- return tokenFileContents.trim();
42
- }
43
- catch (err) {
44
- if (err instanceof Error && "code" in err && err.code === "ENOENT") {
45
- return undefined;
46
- }
47
- throw err;
48
- }
49
- }
50
23
  }
@@ -1,6 +1,6 @@
1
1
  import { ExecRenderBaseCommand } from "../../../rendering/react/ExecRenderBaseCommand.js";
2
2
  import { AppInstaller, } from "../../../lib/app/Installer.js";
3
- const installer = new AppInstaller("0b97d59f-ee13-4f18-a1f6-53e1beaf2e70", "Shopware 6", [
3
+ const installer = new AppInstaller("0b97d59f-ee13-4f18-a1f6-53e1beaf2e70", "Nextcloud", [
4
4
  "version",
5
5
  "host",
6
6
  "admin-user",
@@ -6,6 +6,8 @@ export declare class Init extends ExecRenderBaseCommand<typeof Init, void> {
6
6
  static flags: {
7
7
  "project-name": import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
8
8
  "override-mittwald-plugin": import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
9
+ "database-id": import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
10
+ "without-database": import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
9
11
  "override-type": import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
10
12
  quiet: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
11
13
  };
@@ -15,9 +17,18 @@ export declare class Init extends ExecRenderBaseCommand<typeof Init, void> {
15
17
  protected exec(): Promise<void>;
16
18
  protected render(): React.ReactNode;
17
19
  private addSSHCredentials;
18
- private determineDDEVVersion;
20
+ private getAppInstallation;
19
21
  private installMittwaldPlugin;
20
22
  private initializeDDEVProject;
21
23
  private determineProjectName;
24
+ /**
25
+ * This steps writes the users API token to the local DDEV configuration file.
26
+ * This is necessary to authenticate the DDEV project with the mittwald API.
27
+ *
28
+ * The token is written to the `web_environment` section of the
29
+ * `config.local.yaml`, which _should_ be safe to store credentials in, as it
30
+ * is in DDEV's default `.gitignore` file.
31
+ */
32
+ private writeAuthConfiguration;
22
33
  private writeMittwaldConfiguration;
23
34
  }
@@ -2,22 +2,24 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
2
2
  import { ExecRenderBaseCommand } from "../../rendering/react/ExecRenderBaseCommand.js";
3
3
  import { appInstallationArgs } from "../../lib/app/flags.js";
4
4
  import { makeProcessRenderer, processFlags, } from "../../rendering/process/process_flags.js";
5
- import { mkdir, writeFile } from "fs/promises";
5
+ import { mkdir, readFile, writeFile } from "fs/promises";
6
6
  import path from "path";
7
7
  import { DDEVConfigBuilder } from "../../lib/ddev/config_builder.js";
8
8
  import { spawnInProcess } from "../../rendering/process/process_exec.js";
9
9
  import { Flags } from "@oclif/core";
10
10
  import { DDEVInitSuccess } from "../../rendering/react/components/DDEV/DDEVInitSuccess.js";
11
11
  import { ddevConfigToFlags } from "../../lib/ddev/config.js";
12
- import { hasBinary } from "../../lib/hasbin.js";
13
12
  import { renderDDEVConfig } from "../../lib/ddev/config_render.js";
14
13
  import { loadDDEVConfig } from "../../lib/ddev/config_loader.js";
15
14
  import { Value } from "../../rendering/react/components/Value.js";
16
15
  import { ddevFlags } from "../../lib/ddev/flags.js";
17
- import { exec } from "child_process";
18
- import { promisify } from "util";
19
16
  import { compareSemVer } from "semver-parser";
20
- const execAsync = promisify(exec);
17
+ import { assertStatus } from "@mittwald/api-client";
18
+ import { readApiToken } from "../../lib/auth/token.js";
19
+ import { isNotFound } from "../../lib/fsutil.js";
20
+ import { dump, load } from "js-yaml";
21
+ import { determineDDEVDatabaseId } from "../../lib/ddev/init_database.js";
22
+ import { assertDDEVIsInstalled, determineDDEVVersion, } from "../../lib/ddev/init_assert.js";
21
23
  export class Init extends ExecRenderBaseCommand {
22
24
  static summary = "Initialize a new ddev project in the current directory.";
23
25
  static description = "This command initializes a new ddev configuration for an existing app installation in the current directory.\n" +
@@ -54,8 +56,11 @@ export class Init extends ExecRenderBaseCommand {
54
56
  const appInstallationId = await this.withAppInstallationId(Init);
55
57
  const r = makeProcessRenderer(this.flags, "Initializing DDEV project");
56
58
  await assertDDEVIsInstalled(r);
57
- const ddevVersion = await this.determineDDEVVersion(r);
58
- const config = await this.writeMittwaldConfiguration(r, appInstallationId);
59
+ const ddevVersion = await determineDDEVVersion(r);
60
+ const appInstallation = await this.getAppInstallation(r, appInstallationId);
61
+ const databaseId = await determineDDEVDatabaseId(r, this.apiClient, this.flags, appInstallation);
62
+ await this.writeAuthConfiguration(r);
63
+ const config = await this.writeMittwaldConfiguration(r, appInstallationId, databaseId);
59
64
  const projectName = await this.determineProjectName(r);
60
65
  await this.initializeDDEVProject(r, config, projectName, ddevVersion);
61
66
  await this.installMittwaldPlugin(r);
@@ -71,11 +76,14 @@ export class Init extends ExecRenderBaseCommand {
71
76
  "ssh",
72
77
  ]);
73
78
  }
74
- async determineDDEVVersion(r) {
75
- const { stdout } = await execAsync("ddev --version");
76
- const version = stdout.trim().replace(/^ddev version +/, "");
77
- r.addInfo(_jsx(InfoDDEVVersion, { version: version }));
78
- return version;
79
+ async getAppInstallation(r, appInstallationId) {
80
+ return r.runStep("fetching app installation", async () => {
81
+ const r = await this.apiClient.app.getAppinstallation({
82
+ appInstallationId,
83
+ });
84
+ assertStatus(r, 200);
85
+ return r.data;
86
+ });
79
87
  }
80
88
  async installMittwaldPlugin(r) {
81
89
  const { "override-mittwald-plugin": mittwaldPlugin } = this.flags;
@@ -94,6 +102,7 @@ export class Init extends ExecRenderBaseCommand {
94
102
  if (compareSemVer(ddevVersion, "1.22.7") < 0) {
95
103
  ddevFlags.push("--create-docroot");
96
104
  }
105
+ this.debug("running %o %o", "ddev", ddevFlags);
97
106
  await spawnInProcess(r, "initializing DDEV project", "ddev", ddevFlags);
98
107
  }
99
108
  async determineProjectName(r) {
@@ -108,23 +117,56 @@ export class Init extends ExecRenderBaseCommand {
108
117
  }
109
118
  return await r.addInput("Enter the project name", false);
110
119
  }
111
- async writeMittwaldConfiguration(r, appInstallationId) {
120
+ /**
121
+ * This steps writes the users API token to the local DDEV configuration file.
122
+ * This is necessary to authenticate the DDEV project with the mittwald API.
123
+ *
124
+ * The token is written to the `web_environment` section of the
125
+ * `config.local.yaml`, which _should_ be safe to store credentials in, as it
126
+ * is in DDEV's default `.gitignore` file.
127
+ */
128
+ async writeAuthConfiguration(r) {
129
+ // NOTE that config.local.yaml is in DDEV's default .gitignore file, so
130
+ // it *should* be safe to store credentials in there.
131
+ const configFile = path.join(".ddev", "config.local.yaml");
132
+ const token = await readApiToken(this.config);
133
+ await r.runStep("writing local-only DDEV configuration", async () => {
134
+ try {
135
+ const existing = await readFile(configFile, { encoding: "utf-8" });
136
+ const parsed = load(existing);
137
+ const alreadyContainsAPIToken = (parsed.web_environment ?? []).some((e) => e.startsWith("MITTWALD_API_TOKEN="));
138
+ if (!alreadyContainsAPIToken) {
139
+ parsed.web_environment = [
140
+ ...(parsed.web_environment ?? []),
141
+ `MITTWALD_API_TOKEN=${token}`,
142
+ ];
143
+ await writeContentsToFile(configFile, dump(parsed));
144
+ }
145
+ }
146
+ catch (err) {
147
+ if (isNotFound(err)) {
148
+ const config = {
149
+ web_environment: [`MITTWALD_API_TOKEN=${token}`],
150
+ };
151
+ await writeContentsToFile(configFile, dump(config));
152
+ return;
153
+ }
154
+ else {
155
+ throw err;
156
+ }
157
+ }
158
+ });
159
+ }
160
+ async writeMittwaldConfiguration(r, appInstallationId, databaseId) {
161
+ const builder = new DDEVConfigBuilder(this.apiClient);
112
162
  return await r.runStep("creating mittwald-specific DDEV configuration", async () => {
113
- const builder = new DDEVConfigBuilder(this.apiClient);
114
- const config = await builder.build(appInstallationId, this.flags["override-type"]);
163
+ const config = await builder.build(appInstallationId, databaseId, this.flags["override-type"]);
115
164
  const configFile = path.join(".ddev", "config.mittwald.yaml");
116
165
  await writeContentsToFile(configFile, renderDDEVConfig(appInstallationId, config));
117
166
  return config;
118
167
  });
119
168
  }
120
169
  }
121
- async function assertDDEVIsInstalled(r) {
122
- await r.runStep("check if DDEV is installed", async () => {
123
- if (!(await hasBinary("ddev"))) {
124
- throw new Error("this command requires DDEV to be installed");
125
- }
126
- });
127
- }
128
170
  async function writeContentsToFile(filename, data) {
129
171
  const dirname = path.dirname(filename);
130
172
  await mkdir(dirname, { recursive: true });
@@ -133,6 +175,3 @@ async function writeContentsToFile(filename, data) {
133
175
  function InfoUsingExistingName({ name }) {
134
176
  return (_jsxs(_Fragment, { children: ["using existing project name: ", _jsx(Value, { children: name })] }));
135
177
  }
136
- function InfoDDEVVersion({ version }) {
137
- return (_jsxs(_Fragment, { children: ["detected DDEV version: ", _jsx(Value, { children: version })] }));
138
- }
@@ -3,6 +3,8 @@ export declare class RenderConfig extends ExtendedBaseCommand<typeof RenderConfi
3
3
  static summary: string;
4
4
  static description: string;
5
5
  static flags: {
6
+ "database-id": import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
7
+ "without-database": import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
6
8
  "override-type": import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
7
9
  };
8
10
  static args: {
@@ -15,8 +15,11 @@ export class RenderConfig extends ExtendedBaseCommand {
15
15
  async run() {
16
16
  const appInstallationId = await this.withAppInstallationId(RenderConfig);
17
17
  const projectType = this.flags["override-type"];
18
+ const databaseId = this.flags["without-database"]
19
+ ? "none"
20
+ : this.flags["database-id"];
18
21
  const ddevConfigBuilder = new DDEVConfigBuilder(this.apiClient);
19
- const ddevConfig = await ddevConfigBuilder.build(appInstallationId, projectType);
22
+ const ddevConfig = await ddevConfigBuilder.build(appInstallationId, databaseId, projectType);
20
23
  this.log(renderDDEVConfig(appInstallationId, ddevConfig));
21
24
  }
22
25
  }
@@ -5,32 +5,35 @@ import { Box, Text } from "ink";
5
5
  import { Note } from "../../rendering/react/components/Note.js";
6
6
  import { FancyProcessRenderer } from "../../rendering/process/process_fancy.js";
7
7
  import { Filename } from "../../rendering/react/components/Filename.js";
8
+ import { getTokenFilename } from "../../lib/auth/token.js";
9
+ import { isNotFound } from "../../lib/fsutil.js";
8
10
  export default class Reset extends ExecRenderBaseCommand {
9
11
  static description = "Reset your local authentication state";
10
12
  authenticationRequired = false;
11
13
  async exec() {
12
14
  const process = new FancyProcessRenderer("Resetting authentication state");
15
+ const tokenFilename = getTokenFilename(this.config);
13
16
  process.start();
14
- if (await this.tokenFileExists()) {
15
- const step = process.addStep(_jsxs(Text, { children: ["Deleting token file ", _jsx(Filename, { filename: this.getTokenFilename() })] }));
16
- await fs.rm(this.getTokenFilename(), { force: true });
17
+ if (await this.tokenFileExists(tokenFilename)) {
18
+ const step = process.addStep(_jsxs(Text, { children: ["Deleting token file ", _jsx(Filename, { filename: tokenFilename })] }));
19
+ await fs.rm(tokenFilename, { force: true });
17
20
  step.complete();
18
- process.complete(_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Authentication state successfully reset" }), _jsx(Note, { children: "Please keep in mind that this does not invalidate the token on the server. Invalidate your API token using the mStudio web interface, or using the 'mw user api-token revoke' command." })] }));
21
+ await process.complete(_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Authentication state successfully reset" }), _jsx(Note, { children: "Please keep in mind that this does not invalidate the token on the server. Invalidate your API token using the mStudio web interface, or using the 'mw user api-token revoke' command." })] }));
19
22
  return { deleted: true };
20
23
  }
21
- process.complete(_jsx(Text, { children: "No token file found, nothing to do" }));
24
+ await process.complete(_jsx(Text, { children: "No token file found, nothing to do" }));
22
25
  return { deleted: false };
23
26
  }
24
27
  render() {
25
28
  return null;
26
29
  }
27
- async tokenFileExists() {
30
+ async tokenFileExists(tokenFilename) {
28
31
  try {
29
- await fs.access(this.getTokenFilename());
32
+ await fs.access(tokenFilename);
30
33
  return true;
31
34
  }
32
35
  catch (err) {
33
- if (err instanceof Error && "code" in err && err.code === "ENOENT") {
36
+ if (isNotFound(err)) {
34
37
  return false;
35
38
  }
36
39
  throw err;
@@ -1,6 +1,8 @@
1
1
  import { Flags, ux } from "@oclif/core";
2
2
  import { BaseCommand } from "../../BaseCommand.js";
3
3
  import * as fs from "fs/promises";
4
+ import { getTokenFilename } from "../../lib/auth/token.js";
5
+ import { isNotFound } from "../../lib/fsutil.js";
4
6
  export default class Token extends BaseCommand {
5
7
  static description = "Authenticate using an API token";
6
8
  static flags = {
@@ -21,17 +23,18 @@ export default class Token extends BaseCommand {
21
23
  const token = await ux.prompt("Enter your mStudio API token", {
22
24
  type: "mask",
23
25
  });
26
+ const tokenFilename = getTokenFilename(this.config);
24
27
  await fs.mkdir(this.config.configDir, { recursive: true });
25
- await fs.writeFile(this.getTokenFilename(), token, "utf-8");
26
- this.log("token saved to %o", this.getTokenFilename());
28
+ await fs.writeFile(tokenFilename, token, "utf-8");
29
+ this.log("token saved to %o", tokenFilename);
27
30
  }
28
31
  async tokenFileExists() {
29
32
  try {
30
- await fs.access(this.getTokenFilename());
33
+ await fs.access(getTokenFilename(this.config));
31
34
  return true;
32
35
  }
33
36
  catch (err) {
34
- if (err instanceof Error && "code" in err && err.code === "ENOENT") {
37
+ if (isNotFound(err)) {
35
38
  return false;
36
39
  }
37
40
  throw err;
@@ -2,8 +2,10 @@ import debug from "debug";
2
2
  const d = debug("mw:api-consistency");
3
3
  export function configureConsistencyHandling(axios) {
4
4
  let lastEventId = undefined;
5
+ let mutatedPaths = undefined;
5
6
  axios.interceptors.request.use((config) => {
6
- if (lastEventId !== undefined) {
7
+ if (lastEventId !== undefined &&
8
+ mutatedPaths?.some((path) => config.url?.startsWith(path))) {
7
9
  d("setting if-event-reached to %o", lastEventId);
8
10
  config.headers["if-event-reached"] = lastEventId;
9
11
  }
@@ -13,8 +15,12 @@ export function configureConsistencyHandling(axios) {
13
15
  const isMutatingRequest = ["post", "put", "delete", "patch"].indexOf(response.config?.method?.toLowerCase() ?? "") >= 0;
14
16
  const headers = response.headers;
15
17
  if (headers.has("etag") && isMutatingRequest) {
16
- d("setting last event id to %o after mutating request", headers.get("etag"));
17
18
  lastEventId = headers.get("etag");
19
+ const mutatedPath = response.config?.url;
20
+ if (mutatedPath !== undefined) {
21
+ mutatedPaths = [mutatedPath];
22
+ }
23
+ d("setting last event id to %o after mutating request for path %o", headers["etag"], mutatedPath);
18
24
  }
19
25
  return response;
20
26
  });
@@ -2,16 +2,26 @@ import debug from "debug";
2
2
  import axiosRetry from "axios-retry";
3
3
  const d = debug("mw:api-retry");
4
4
  export function configureAxiosRetry(axios) {
5
+ let shouldRetryAccessDenied = false;
5
6
  axios.interceptors.request.use((config) => {
6
7
  return {
7
8
  ...config,
8
9
  validateStatus: (status) => status < 300,
9
10
  };
10
11
  });
12
+ axios.interceptors.request.use((config) => {
13
+ if (config.method?.toLowerCase() === "post") {
14
+ shouldRetryAccessDenied = true;
15
+ }
16
+ return config;
17
+ });
11
18
  axiosRetry(axios, {
12
19
  retries: 10,
13
20
  retryDelay: axiosRetry.exponentialDelay,
14
- onRetry(count, error) {
21
+ onRetry(count, error, config) {
22
+ if (error.response?.status === 412 && config.headers !== undefined) {
23
+ delete config.headers["if-event-reached"];
24
+ }
15
25
  d("retrying request after %d attempts; error: %o", count, error.message);
16
26
  },
17
27
  retryCondition(error) {
@@ -22,8 +32,12 @@ export function configureAxiosRetry(axios) {
22
32
  return true;
23
33
  }
24
34
  const isSafeRequest = error.config?.method?.toLowerCase() === "get";
35
+ const isPreconditionFailed = error.response?.status === 412;
25
36
  const isAccessDenied = error.response?.status === 403;
26
- return isSafeRequest && isAccessDenied;
37
+ if (isPreconditionFailed) {
38
+ return true;
39
+ }
40
+ return isSafeRequest && isAccessDenied && shouldRetryAccessDenied;
27
41
  },
28
42
  });
29
43
  }
@@ -0,0 +1,13 @@
1
+ import { Config } from "@oclif/core";
2
+ /**
3
+ * Gets the filename in which the CLI should store the API token.
4
+ *
5
+ * @param config The CLI configuration object.
6
+ */
7
+ export declare function getTokenFilename(config: Config): string;
8
+ /**
9
+ * Reads the API token from the environment or the configuration file.
10
+ *
11
+ * @param config The CLI configuration object.
12
+ */
13
+ export declare function readApiToken(config: Config): Promise<string | undefined>;
@@ -0,0 +1,44 @@
1
+ import path from "path";
2
+ import fs from "fs/promises";
3
+ import { isNotFound } from "../fsutil.js";
4
+ /**
5
+ * Gets the filename in which the CLI should store the API token.
6
+ *
7
+ * @param config The CLI configuration object.
8
+ */
9
+ export function getTokenFilename(config) {
10
+ return path.join(config.configDir, "token");
11
+ }
12
+ /**
13
+ * Reads the API token from the environment or the configuration file.
14
+ *
15
+ * @param config The CLI configuration object.
16
+ */
17
+ export async function readApiToken(config) {
18
+ return (readApiTokenFromEnvironment() ?? (await readApiTokenFromConfig(config)));
19
+ }
20
+ /** Reads the API token from the MITTWALD_API_TOKEN environment variable. */
21
+ function readApiTokenFromEnvironment() {
22
+ const token = process.env.MITTWALD_API_TOKEN;
23
+ if (token === undefined) {
24
+ return undefined;
25
+ }
26
+ return token.trim();
27
+ }
28
+ /**
29
+ * Reads the API token from the configuration file.
30
+ *
31
+ * @param config The CLI configuration object.
32
+ */
33
+ async function readApiTokenFromConfig(config) {
34
+ try {
35
+ const tokenFileContents = await fs.readFile(getTokenFilename(config), "utf-8");
36
+ return tokenFileContents.trim();
37
+ }
38
+ catch (err) {
39
+ if (isNotFound(err)) {
40
+ return undefined;
41
+ }
42
+ throw err;
43
+ }
44
+ }
@@ -3,11 +3,13 @@ import { DDEVConfig } from "./config.js";
3
3
  export declare class DDEVConfigBuilder {
4
4
  private apiClient;
5
5
  constructor(apiClient: MittwaldAPIV2Client);
6
- build(appInstallationId: string, type: string): Promise<Partial<DDEVConfig>>;
6
+ build(appInstallationId: string, databaseId: string | undefined, type: string): Promise<Partial<DDEVConfig>>;
7
7
  private buildHooks;
8
8
  private determineDocumentRoot;
9
9
  private determineProjectType;
10
10
  private determineDatabaseVersion;
11
+ private determineMySQLDatabaseVersion;
12
+ private determineDatabaseVersionFromInstallation;
11
13
  private determinePHPVersion;
12
14
  private buildSystemSoftwareVersionMap;
13
15
  private getSystemSoftware;
@@ -8,19 +8,19 @@ export class DDEVConfigBuilder {
8
8
  constructor(apiClient) {
9
9
  this.apiClient = apiClient;
10
10
  }
11
- async build(appInstallationId, type) {
11
+ async build(appInstallationId, databaseId, type) {
12
12
  const appInstallation = await this.getAppInstallation(appInstallationId);
13
13
  const systemSoftwares = await this.buildSystemSoftwareVersionMap(appInstallation);
14
14
  type = await this.determineProjectType(appInstallation, type);
15
15
  return {
16
- override_config: true,
17
16
  type,
18
17
  webserver_type: "apache-fpm",
19
18
  php_version: this.determinePHPVersion(systemSoftwares),
20
- database: await this.determineDatabaseVersion(appInstallation),
19
+ database: await this.determineDatabaseVersion(databaseId),
21
20
  docroot: await this.determineDocumentRoot(appInstallation),
22
21
  web_environment: [
23
22
  `MITTWALD_APP_INSTALLATION_ID=${appInstallation.shortId}`,
23
+ `MITTWALD_DATABASE_ID=${databaseId ?? ""}`,
24
24
  ],
25
25
  hooks: this.buildHooks(type),
26
26
  };
@@ -70,18 +70,33 @@ export class DDEVConfigBuilder {
70
70
  throw new Error("Automatic project type detection failed. Please specify the project type manually by setting the `--override-type` flag.");
71
71
  }
72
72
  }
73
- async determineDatabaseVersion(inst) {
73
+ async determineDatabaseVersion(databaseId) {
74
+ if (!databaseId) {
75
+ return undefined;
76
+ }
77
+ const mysqlDatabase = await this.determineMySQLDatabaseVersion(databaseId);
78
+ if (mysqlDatabase) {
79
+ return mysqlDatabase;
80
+ }
81
+ return undefined;
82
+ }
83
+ async determineMySQLDatabaseVersion(mysqlDatabaseId) {
84
+ const r = await this.apiClient.database.getMysqlDatabase({
85
+ mysqlDatabaseId,
86
+ });
87
+ if (r.status !== 200) {
88
+ return undefined;
89
+ }
90
+ return {
91
+ type: "mysql",
92
+ version: r.data.version,
93
+ };
94
+ }
95
+ async determineDatabaseVersionFromInstallation(inst) {
74
96
  const isPrimary = (db) => db.purpose === "primary";
75
97
  const primary = (inst.linkedDatabases || []).find(isPrimary);
76
98
  if (primary?.kind === "mysql") {
77
- const r = await this.apiClient.database.getMysqlDatabase({
78
- mysqlDatabaseId: primary.databaseId,
79
- });
80
- assertStatus(r, 200);
81
- return {
82
- type: "mysql",
83
- version: r.data.version,
84
- };
99
+ return this.determineMySQLDatabaseVersion(primary.databaseId);
85
100
  }
86
101
  return undefined;
87
102
  }
@@ -1,3 +1,12 @@
1
+ import { InferredFlags } from "@oclif/core/lib/interfaces/index.js";
2
+ declare const ddevDatabaseFlags: {
3
+ "database-id": import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
4
+ "without-database": import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
5
+ };
1
6
  export declare const ddevFlags: {
7
+ "database-id": import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
8
+ "without-database": import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
2
9
  "override-type": import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
3
10
  };
11
+ export type DDEVDatabaseFlags = InferredFlags<typeof ddevDatabaseFlags>;
12
+ export {};
@@ -1,4 +1,20 @@
1
1
  import { Flags } from "@oclif/core";
2
+ const ddevDatabaseFlags = {
3
+ "database-id": Flags.string({
4
+ summary: "ID of the application database",
5
+ description: "The ID of the database to use for the DDEV project; if set to 'auto', the command will use the database linked to the app installation.\n" +
6
+ "\n" +
7
+ "Setting a database ID (either automatically or manually) is required. To create a DDEV project without a database, set the --without-database flag.",
8
+ required: false,
9
+ default: undefined,
10
+ }),
11
+ "without-database": Flags.boolean({
12
+ summary: "Create a DDEV project without a database",
13
+ description: "Use this flag to create a DDEV project without a database; this is useful for projects that do not require a database.",
14
+ default: false,
15
+ exclusive: ["database-id"],
16
+ }),
17
+ };
2
18
  export const ddevFlags = {
3
19
  "override-type": Flags.string({
4
20
  summary: "Override the type of the generated DDEV configuration",
@@ -7,4 +23,5 @@ export const ddevFlags = {
7
23
  "\n\n" +
8
24
  "See https://ddev.readthedocs.io/en/latest/users/configuration/config/#type for more information",
9
25
  }),
26
+ ...ddevDatabaseFlags,
10
27
  };
@@ -0,0 +1,3 @@
1
+ import { ProcessRenderer } from "../../rendering/process/process.js";
2
+ export declare function assertDDEVIsInstalled(r: ProcessRenderer): Promise<void>;
3
+ export declare function determineDDEVVersion(r: ProcessRenderer): Promise<string>;
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { hasBinary } from "../hasbin.js";
3
+ import { promisify } from "util";
4
+ import { exec } from "child_process";
5
+ import { Value } from "../../rendering/react/components/Value.js";
6
+ const execAsync = promisify(exec);
7
+ export async function assertDDEVIsInstalled(r) {
8
+ await r.runStep("check if DDEV is installed", async () => {
9
+ if (!(await hasBinary("ddev"))) {
10
+ throw new Error("this command requires DDEV to be installed");
11
+ }
12
+ });
13
+ }
14
+ export async function determineDDEVVersion(r) {
15
+ const { stdout } = await execAsync("ddev --version");
16
+ const version = stdout.trim().replace(/^ddev version +/, "");
17
+ r.addInfo(_jsx(InfoDDEVVersion, { version: version }));
18
+ return version;
19
+ }
20
+ function InfoDDEVVersion({ version }) {
21
+ return (_jsxs(_Fragment, { children: ["detected DDEV version: ", _jsx(Value, { children: version })] }));
22
+ }
@@ -0,0 +1,19 @@
1
+ import { ProcessRenderer } from "../../rendering/process/process.js";
2
+ import { DDEVDatabaseFlags } from "./flags.js";
3
+ import { type MittwaldAPIV2, type MittwaldAPIV2Client } from "@mittwald/api-client";
4
+ type AppInstallation = MittwaldAPIV2.Components.Schemas.AppAppInstallation;
5
+ /**
6
+ * Determines the database ID to use for the DDEV project.
7
+ *
8
+ * This is done according to the following rules (in order of precedence):
9
+ *
10
+ * 1. If the `--without-database` flag is set, do not use a database.
11
+ * 2. If the `--database-id` flag is set, use the database with the given ID.
12
+ * 3. If the app installation has a linked database with the purpose "primary", use
13
+ * that database.
14
+ * 4. Otherwise, prompt the user to select a database from the list of databases
15
+ * present in the project.
16
+ * 5. If interactive input is not available, terminate with an error.
17
+ */
18
+ export declare function determineDDEVDatabaseId(r: ProcessRenderer, apiClient: MittwaldAPIV2Client, flags: DDEVDatabaseFlags, appInstallation: AppInstallation): Promise<string | undefined>;
19
+ export {};
@@ -0,0 +1,59 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { assertStatus, } from "@mittwald/api-client";
3
+ import { Value } from "../../rendering/react/components/Value.js";
4
+ import { Text } from "ink";
5
+ /**
6
+ * Determines the database ID to use for the DDEV project.
7
+ *
8
+ * This is done according to the following rules (in order of precedence):
9
+ *
10
+ * 1. If the `--without-database` flag is set, do not use a database.
11
+ * 2. If the `--database-id` flag is set, use the database with the given ID.
12
+ * 3. If the app installation has a linked database with the purpose "primary", use
13
+ * that database.
14
+ * 4. Otherwise, prompt the user to select a database from the list of databases
15
+ * present in the project.
16
+ * 5. If interactive input is not available, terminate with an error.
17
+ */
18
+ export async function determineDDEVDatabaseId(r, apiClient, flags, appInstallation) {
19
+ let databaseId = flags["database-id"];
20
+ const withoutDatabase = flags["without-database"];
21
+ if (withoutDatabase) {
22
+ return undefined;
23
+ }
24
+ if (databaseId === undefined) {
25
+ databaseId = (appInstallation.linkedDatabases ?? []).find((db) => db.purpose === "primary")?.databaseId;
26
+ }
27
+ if (databaseId !== undefined) {
28
+ const mysqlDatabaseResponse = await apiClient.database.getMysqlDatabase({
29
+ mysqlDatabaseId: databaseId,
30
+ });
31
+ assertStatus(mysqlDatabaseResponse, 200);
32
+ r.addInfo(_jsx(InfoDatabase, { name: mysqlDatabaseResponse.data.name }));
33
+ return mysqlDatabaseResponse.data.name;
34
+ }
35
+ return await promptDatabaseFromUser(r, apiClient, appInstallation);
36
+ }
37
+ async function promptDatabaseFromUser(r, apiClient, appInstallation) {
38
+ const { projectId } = appInstallation;
39
+ if (!projectId) {
40
+ throw new Error("app installation has no project ID");
41
+ }
42
+ const mysqlDatabaseResponse = await apiClient.database.listMysqlDatabases({
43
+ projectId,
44
+ });
45
+ assertStatus(mysqlDatabaseResponse, 200);
46
+ return await r.addSelect("select the database to use", [
47
+ ...mysqlDatabaseResponse.data.map((db) => ({
48
+ value: db.name,
49
+ label: `${db.name} (${db.description})`,
50
+ })),
51
+ {
52
+ value: undefined,
53
+ label: "no database",
54
+ },
55
+ ]);
56
+ }
57
+ function InfoDatabase({ name }) {
58
+ return (_jsxs(Text, { children: ["using database: ", _jsx(Value, { children: name })] }));
59
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Error thrown when a command requires interactive input, but the current
3
+ * terminal environment does not support it.
4
+ */
5
+ export default class InteractiveInputRequiredError extends Error {
6
+ constructor();
7
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Error thrown when a command requires interactive input, but the current
3
+ * terminal environment does not support it.
4
+ */
5
+ export default class InteractiveInputRequiredError extends Error {
6
+ constructor() {
7
+ super("This command requires an interactive input, but the current environment " +
8
+ "does not support it. Please have a look at this command's --help page " +
9
+ "to learn how to pass the required input non-interactively.");
10
+ }
11
+ }
@@ -0,0 +1 @@
1
+ export declare const InteractiveInputDisabled: () => import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,3 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Text } from "ink";
3
+ export const InteractiveInputDisabled = () => (_jsx(Text, { color: "red", children: "interactive input required; inspect this command's --help page to learn how to pass the required input non-interactively." }));
@@ -4,7 +4,7 @@ import { ProcessStateIcon } from "./ProcessStateIcon.js";
4
4
  import { ProcessState } from "./ProcessState.js";
5
5
  import { Box, Text, useStdin } from "ink";
6
6
  import TextInput from "ink-text-input";
7
- const InteractiveInputDisabled = () => (_jsx(Text, { color: "red", children: "interactive input required; inspect this command's --help page to learn how to pass the required input non-interactively." }));
7
+ import { InteractiveInputDisabled } from "./InteractiveInputDisabled.js";
8
8
  export const ProcessInput = ({ step, onSubmit }) => {
9
9
  const [value, setValue] = useState("");
10
10
  if (!step.value) {
@@ -0,0 +1,6 @@
1
+ import { ProcessStepSelect } from "../process.js";
2
+ export declare function ProcessSelect<TVal>({ step, onSubmit, onError, }: {
3
+ step: ProcessStepSelect<TVal>;
4
+ onSubmit: (_: TVal) => void;
5
+ onError?: (err: unknown) => void;
6
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,61 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { ProcessStateIcon } from "./ProcessStateIcon.js";
4
+ import { ProcessState } from "./ProcessState.js";
5
+ import { Box, Text, useInput, useStdin } from "ink";
6
+ import { InteractiveInputDisabled } from "./InteractiveInputDisabled.js";
7
+ import InteractiveInputRequiredError from "../../../lib/error/InteractiveInputRequiredError.js";
8
+ function useSelectControls(initial, length, onSelect) {
9
+ const { isRawModeSupported } = useStdin();
10
+ const [value, setValue] = useState(initial);
11
+ const [extraUsageHint, setExtraUsageHint] = useState(false);
12
+ if (!isRawModeSupported) {
13
+ return {
14
+ value: undefined,
15
+ extraUsageHint: false,
16
+ };
17
+ }
18
+ useInput((_, key) => {
19
+ if (key.return) {
20
+ if (value !== undefined) {
21
+ setValue(value);
22
+ onSelect(value);
23
+ }
24
+ else {
25
+ setExtraUsageHint(true);
26
+ }
27
+ }
28
+ else if (key.downArrow) {
29
+ setExtraUsageHint(false);
30
+ setValue((prev) => prev === undefined ? 0 : Math.min(length - 1, prev + 1));
31
+ }
32
+ else if (key.upArrow) {
33
+ setExtraUsageHint(false);
34
+ setValue((prev) => (prev === undefined ? 0 : Math.max(prev - 1, 0)));
35
+ }
36
+ });
37
+ return {
38
+ value,
39
+ extraUsageHint,
40
+ };
41
+ }
42
+ export function ProcessSelect({ step, onSubmit, onError, }) {
43
+ const { value, extraUsageHint } = useSelectControls(Math.max(step.options.findIndex((o) => o.value === step.selected), 0), step.options.length, (idx) => onSubmit(step.options[idx].value));
44
+ const { isRawModeSupported } = useStdin();
45
+ if (step.selected === undefined) {
46
+ if (!isRawModeSupported) {
47
+ useEffect(() => {
48
+ onError && onError(new InteractiveInputRequiredError());
49
+ });
50
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { marginX: 2, children: [_jsx(ProcessStateIcon, { step: step }), _jsxs(Text, { children: [step.title, ": "] })] }), _jsx(Box, { marginX: 8, children: _jsx(InteractiveInputDisabled, {}) })] }));
51
+ }
52
+ return (_jsxs(_Fragment, { children: [_jsxs(Box, { marginX: 2, children: [_jsx(ProcessStateIcon, { step: step }), _jsxs(Text, { children: [step.title, ": "] })] }), _jsxs(Box, { flexDirection: "column", children: [_jsx(SelectUsageHint, { extraHint: extraUsageHint }), step.options.map((option, index) => (_jsx(SelectOption, { selected: index === value, label: option.label }, index)))] })] }));
53
+ }
54
+ return _jsx(ProcessState, { step: step });
55
+ }
56
+ function SelectUsageHint({ extraHint }) {
57
+ return (_jsx(Box, { marginLeft: 4, children: _jsx(Text, { color: extraHint ? "red" : "gray", bold: extraHint, children: "(use the up and down keys to select an option)" }) }));
58
+ }
59
+ function SelectOption({ selected, label, }) {
60
+ return (_jsx(Box, { marginLeft: 4, children: _jsxs(Text, { color: "blue", children: [selected ? "👉︎ " : " ", label] }) }));
61
+ }
@@ -0,0 +1,4 @@
1
+ import { ProcessStepSelect } from "../process.js";
2
+ export declare function ProcessSelectStateSummary<TVal>({ step, }: {
3
+ step: ProcessStepSelect<TVal>;
4
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Text } from "ink";
3
+ export function ProcessSelectStateSummary({ step, }) {
4
+ if (step.selected) {
5
+ return (_jsxs(_Fragment, { children: [_jsx(Text, { children: ": " }), _jsx(Text, { color: "green", children: step.options.find((o) => o.value === step.selected)?.label })] }));
6
+ }
7
+ else {
8
+ return _jsx(Text, { children: ": " });
9
+ }
10
+ }
@@ -4,7 +4,9 @@ export const ProcessStateIcon = ({ step }) => {
4
4
  if (step.type === "info") {
5
5
  return _jsx(Text, { children: "\uD83D\uDCA1 " });
6
6
  }
7
- else if (step.type === "confirm" || step.type === "input") {
7
+ else if (step.type === "confirm" ||
8
+ step.type === "input" ||
9
+ step.type === "select") {
8
10
  return _jsx(Text, { children: "\u2753" });
9
11
  }
10
12
  else if (step.phase === "completed") {
@@ -2,6 +2,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import { ProcessConfirmationStateSummary } from "./ProcessConfirmationStateSummary.js";
3
3
  import { ProcessInputStateSummary } from "./ProcessInputStateSummary.js";
4
4
  import { Text } from "ink";
5
+ import { ProcessSelectStateSummary } from "./ProcessSelectStateSummary.js";
5
6
  export const ProcessStateSummary = ({ step, }) => {
6
7
  if (step.type === "info") {
7
8
  return _jsx(_Fragment, {});
@@ -12,6 +13,9 @@ export const ProcessStateSummary = ({ step, }) => {
12
13
  else if (step.type === "input") {
13
14
  return _jsx(ProcessInputStateSummary, { step: step });
14
15
  }
16
+ else if (step.type === "select") {
17
+ return _jsx(ProcessSelectStateSummary, { step: step });
18
+ }
15
19
  else if (step.phase === "completed") {
16
20
  return (_jsxs(_Fragment, { children: [_jsx(Text, { children: ". " }), _jsx(Text, { color: "green", children: "done" })] }));
17
21
  }
@@ -22,7 +22,16 @@ export type ProcessStepInput = {
22
22
  mask?: boolean;
23
23
  value?: string;
24
24
  };
25
- export type ProcessStep = ProcessStepInfo | ProcessStepRunnable | ProcessStepConfirm | ProcessStepInput;
25
+ export type ProcessStepSelect<TVal> = {
26
+ type: "select";
27
+ title: ReactNode;
28
+ options: {
29
+ value: TVal;
30
+ label: ReactNode;
31
+ }[];
32
+ selected?: TVal;
33
+ };
34
+ export type ProcessStep = ProcessStepInfo | ProcessStepRunnable | ProcessStepConfirm | ProcessStepInput | ProcessStepSelect<unknown>;
26
35
  export type CleanupFunction = {
27
36
  title: ReactNode;
28
37
  fn: () => Promise<unknown>;
@@ -49,6 +58,10 @@ export interface ProcessRenderer {
49
58
  addInfo(title: ReactElement): void;
50
59
  addConfirmation(question: ReactElement): Promise<boolean>;
51
60
  addInput(question: ReactNode, mask?: boolean): Promise<string>;
61
+ addSelect<TVal>(question: ReactNode, options: {
62
+ value: TVal;
63
+ label: ReactNode;
64
+ }[]): Promise<TVal>;
52
65
  addCleanup(title: ReactNode, fn: () => Promise<unknown>): void;
53
66
  complete(summary: ReactElement): Promise<void>;
54
67
  error(err: unknown): Promise<void>;
@@ -11,6 +11,10 @@ export declare class FancyProcessRenderer implements ProcessRenderer {
11
11
  runStep<TRes>(title: ReactNode, fn: () => Promise<TRes>): Promise<TRes>;
12
12
  addInfo(title: ReactElement): void;
13
13
  addInput(question: React.ReactElement, mask?: boolean): Promise<string>;
14
+ addSelect<TVal>(question: React.ReactNode, options: {
15
+ value: TVal;
16
+ label: React.ReactNode;
17
+ }[]): Promise<TVal>;
14
18
  addConfirmation(question: ReactElement): Promise<boolean>;
15
19
  complete(summary: ReactElement): Promise<void>;
16
20
  error(err: unknown): Promise<void>;
@@ -5,6 +5,7 @@ import { Box, render, Text } from "ink";
5
5
  import { ProcessState } from "./components/ProcessState.js";
6
6
  import { ProcessConfirmation } from "./components/ProcessConfirmation.js";
7
7
  import { ProcessInput } from "./components/ProcessInput.js";
8
+ import { ProcessSelect } from "./components/ProcessSelect.js";
8
9
  export class FancyProcessRenderer {
9
10
  title;
10
11
  started = false;
@@ -78,6 +79,35 @@ export class FancyProcessRenderer {
78
79
  const renderHandle = render(_jsx(ProcessInput, { step: state, onSubmit: onInput }));
79
80
  });
80
81
  }
82
+ addSelect(question, options) {
83
+ this.start();
84
+ if (this.currentHandler !== null) {
85
+ this.currentHandler.complete();
86
+ }
87
+ const state = {
88
+ type: "select",
89
+ title: question,
90
+ options,
91
+ selected: undefined,
92
+ };
93
+ return new Promise((res, rej) => {
94
+ const onSelect = (selected) => {
95
+ res(selected);
96
+ state.selected = selected;
97
+ if (renderHandle) {
98
+ renderHandle.rerender(_jsx(ProcessSelect, { step: state, onSubmit: onSelect }));
99
+ renderHandle.unmount();
100
+ }
101
+ };
102
+ const onError = (err) => {
103
+ if (renderHandle) {
104
+ renderHandle.unmount();
105
+ }
106
+ rej(err);
107
+ };
108
+ const renderHandle = render(_jsx(ProcessSelect, { step: state, onSubmit: onSelect, onError: onError }));
109
+ });
110
+ }
81
111
  addConfirmation(question) {
82
112
  this.start();
83
113
  if (this.currentHandler !== null) {
@@ -10,6 +10,7 @@ export declare class SilentProcessRenderer implements ProcessRenderer {
10
10
  error(err: unknown): Promise<void>;
11
11
  addConfirmation(): Promise<boolean>;
12
12
  addInput(): Promise<string>;
13
+ addSelect<TVal>(): Promise<TVal>;
13
14
  addCleanup(_: ReactNode, fn: () => Promise<unknown>): void;
14
15
  private cleanup;
15
16
  }
@@ -29,6 +29,9 @@ export class SilentProcessRenderer {
29
29
  addInput() {
30
30
  throw new Error("no interactive input available in quiet mode");
31
31
  }
32
+ addSelect() {
33
+ throw new Error("no interactive input available in quiet mode");
34
+ }
32
35
  addCleanup(_, fn) {
33
36
  this.cleanupFns.push(fn);
34
37
  }
@@ -1,8 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
- import Link from "ink-link";
4
3
  import { FailedFlagValidationError, RequiredArgsError, } from "@oclif/core/lib/parser/errors.js";
5
4
  import { ApiClientError, } from "@mittwald/api-client-commons";
5
+ import InteractiveInputRequiredError from "../../../lib/error/InteractiveInputRequiredError.js";
6
6
  const color = "red";
7
7
  const issueURL = "https://github.com/mittwald/cli/issues/new";
8
8
  const boxProps = {
@@ -16,8 +16,8 @@ const boxProps = {
16
16
  const ErrorStack = ({ err }) => {
17
17
  return (_jsxs(Box, { marginX: 2, marginY: 1, flexDirection: "column", rowGap: 1, children: [_jsx(Text, { color: color, dimColor: true, bold: true, children: "ERROR STACK TRACE" }), _jsx(Text, { color: color, dimColor: true, children: "Please provide this when opening a bug report." }), _jsx(Text, { color: color, dimColor: true, children: err.stack })] }));
18
18
  };
19
- const GenericError = ({ err, withStack, }) => {
20
- return (_jsxs(_Fragment, { children: [_jsxs(Box, { ...boxProps, borderColor: color, children: [_jsx(Text, { color: color, bold: true, underline: true, children: "ERROR" }), _jsx(Text, { color: color, children: "An error occurred while executing this command:" }), _jsx(Box, { marginX: 2, children: _jsx(Text, { color: color, children: err.toString() }) }), _jsxs(Text, { color: color, children: ["If you believe this to be a bug, please open an issue at", " ", _jsx(Link, { url: issueURL, children: issueURL }), "."] })] }), withStack && "stack" in err ? _jsx(ErrorStack, { err: err }) : undefined] }));
19
+ const GenericError = ({ err, withStack, withIssue = true, title = "Error" }) => {
20
+ return (_jsxs(_Fragment, { children: [_jsxs(Box, { ...boxProps, borderColor: color, children: [_jsx(Text, { color: color, bold: true, underline: true, children: title.toUpperCase() }), _jsx(Text, { color: color, children: "An error occurred while executing this command:" }), _jsx(Box, { marginX: 2, children: _jsx(Text, { color: color, children: err.toString() }) }), withIssue ? (_jsxs(Text, { color: color, children: ["If you believe this to be a bug, please open an issue at ", issueURL, "."] })) : undefined] }), withStack && "stack" in err ? _jsx(ErrorStack, { err: err }) : undefined] }));
21
21
  };
22
22
  const InvalidFlagsError = ({ err }) => {
23
23
  const color = "yellow";
@@ -63,6 +63,9 @@ export const ErrorBox = ({ err }) => {
63
63
  else if (err instanceof ApiClientError) {
64
64
  return _jsx(ApiError, { err: err, withStack: true, withHTTPMessages: "body" });
65
65
  }
66
+ else if (err instanceof InteractiveInputRequiredError) {
67
+ return (_jsx(GenericError, { err: err, withStack: false, withIssue: false, title: "Input required" }));
68
+ }
66
69
  else if (err instanceof Error) {
67
70
  return _jsx(GenericError, { err: err, withStack: true });
68
71
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mittwald/cli",
3
- "version": "1.0.0-alpha.39",
3
+ "version": "1.0.0-alpha.40",
4
4
  "description": "Hand-crafted CLI for the mittwald API",
5
5
  "license": "MIT",
6
6
  "author": {