@webship.co/workspace 1.0.3 → 1.0.5

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 (78) hide show
  1. package/core/docs/releases.md +1 -1
  2. package/core/scripts/args/arg-website.sh +2 -2
  3. package/core/scripts/functions/fun-build-distribution.sh +48 -39
  4. package/core/scripts/functions/fun-build-webships.sh +3 -2
  5. package/core/scripts/functions/fun-build-website.sh +19 -7
  6. package/core/scripts/functions/fun-distribution-cucumber.sh +18 -3
  7. package/core/scripts/functions/fun-distribution-webship.sh +4 -2
  8. package/core/scripts/functions/fun-distribution-website.sh +4 -2
  9. package/core/scripts/functions/fun-users.sh +11 -0
  10. package/core/scripts/tests/cmd-smoke-test.sh +15 -1
  11. package/core/workspace-app/docs/README.md +4 -3
  12. package/core/workspace-app/docs/USER-GUIDE.md +2 -2
  13. package/core/workspace-app/docs/screenshots/assistant/avatar-board.png +0 -0
  14. package/core/workspace-app/docs/screenshots/assistant/avatar-colours.png +0 -0
  15. package/core/workspace-app/docs/screenshots/assistant/avatar-icons.png +0 -0
  16. package/core/workspace-app/docs/screenshots/assistant/avatar-moves.png +0 -0
  17. package/core/workspace-app/docs/screenshots/assistant/avatar-settings-voices.png +0 -0
  18. package/core/workspace-app/docs/screenshots/create-card/create-card.png +0 -0
  19. package/core/workspace-app/docs/screenshots/create-card/start-ai.png +0 -0
  20. package/core/workspace-app/docs/screenshots/guide-clone-address.png +0 -0
  21. package/core/workspace-app/docs/screenshots/guide-clone-worktree-off.png +0 -0
  22. package/core/workspace-app/docs/screenshots/guide-clone-worktree-on.png +0 -0
  23. package/core/workspace-app/docs/screenshots/guide-ddev-menu.png +0 -0
  24. package/core/workspace-app/docs/screenshots/guide-domains.png +0 -0
  25. package/core/workspace-app/docs/screenshots/guide-home.png +0 -0
  26. package/core/workspace-app/docs/screenshots/guide-ide-menu.png +0 -0
  27. package/core/workspace-app/docs/screenshots/guide-remove-refusal.png +0 -0
  28. package/core/workspace-app/docs/screenshots/guide-rows-worktree.png +0 -0
  29. package/core/workspace-app/docs/screenshots/guide-screens.png +0 -0
  30. package/core/workspace-app/docs/screenshots/guide-settings.png +0 -0
  31. package/core/workspace-app/docs/screenshots/guide-workspace-page.png +0 -0
  32. package/core/workspace-app/docs/screenshots/home/card-settings.png +0 -0
  33. package/core/workspace-app/docs/screenshots/home/drag-reorder.png +0 -0
  34. package/core/workspace-app/docs/screenshots/home/edit-card.png +0 -0
  35. package/core/workspace-app/docs/screenshots/images/claude-code-image.png +0 -0
  36. package/core/workspace-app/docs/screenshots/images/edit-with-ai.png +0 -0
  37. package/core/workspace-app/docs/screenshots/images/gallery.png +0 -0
  38. package/core/workspace-app/docs/screenshots/images/generate-description.png +0 -0
  39. package/core/workspace-app/docs/screenshots/images/generate-plan.png +0 -0
  40. package/core/workspace-app/docs/screenshots/images/providers.png +0 -0
  41. package/core/workspace-app/docs/screenshots/images/svg-form.png +0 -0
  42. package/core/workspace-app/docs/screenshots/images/svg-result.png +0 -0
  43. package/core/workspace-app/docs/screenshots/images/upload.png +0 -0
  44. package/core/workspace-app/docs/screenshots/new-folder/2-form.png +0 -0
  45. package/core/workspace-app/docs/screenshots/new-folder/8-ddev-options.png +0 -0
  46. package/core/workspace-app/docs/screenshots/new-folder/files-folder.png +0 -0
  47. package/core/workspace-app/docs/screenshots/projects/ddev-custom-commands.png +0 -0
  48. package/core/workspace-app/docs/screenshots/projects/state-chip-stop.png +0 -0
  49. package/core/workspace-app/docs/screenshots/readme/dashboard-dark.png +0 -0
  50. package/core/workspace-app/docs/screenshots/readme/dashboard-light.png +0 -0
  51. package/core/workspace-app/docs/screenshots/readme/dev-dark.png +0 -0
  52. package/core/workspace-app/docs/screenshots/readme/dev-light.png +0 -0
  53. package/core/workspace-app/docs/screenshots/readme/docs-dark.png +0 -0
  54. package/core/workspace-app/docs/screenshots/readme/docs-light.png +0 -0
  55. package/core/workspace-app/docs/screenshots/readme/videos-dark.png +0 -0
  56. package/core/workspace-app/docs/screenshots/readme/videos-light.png +0 -0
  57. package/core/workspace-app/docs/screenshots/screens-menu.png +0 -0
  58. package/core/workspace-app/docs/screenshots/screens-new.png +0 -0
  59. package/core/workspace-app/docs/screenshots/settings/auth-google-lists.png +0 -0
  60. package/core/workspace-app/docs/screenshots/settings/delete-1-row.png +0 -0
  61. package/core/workspace-app/docs/screenshots/settings/delete-2-confirm.png +0 -0
  62. package/core/workspace-app/docs/screenshots/settings/delete-3-type-name.png +0 -0
  63. package/core/workspace-app/docs/screenshots/settings/delete-4-type-yes.png +0 -0
  64. package/core/workspace-app/package.json +1 -1
  65. package/core/workspace-app/public/avatar/druplicon.vrm +0 -0
  66. package/core/workspace-app/server.js +1 -1
  67. package/core/workspace-app/tests/unit/build-distribution.test.js +44 -0
  68. package/core/workspace-app/tests/unit/build-website.test.js +116 -0
  69. package/core/workspace-app/tests/unit/distribution-users.test.js +109 -0
  70. package/core/workspace-app/tests/unit/files-route-stream.test.js +16 -0
  71. package/package.json +1 -1
  72. package/core/workspace-app/docs/screenshots/assistant/avatar-coffee.png +0 -0
  73. package/core/workspace-app/docs/screenshots/assistant/avatar-pick-cookieman.png +0 -0
  74. package/core/workspace-app/docs/screenshots/assistant/avatar-status.png +0 -0
  75. package/core/workspace-app/docs/screenshots/readme/test-report-dark.png +0 -0
  76. package/core/workspace-app/docs/screenshots/readme/test-report-light.png +0 -0
  77. package/core/workspace-app/docs/screenshots/readme/test-scenarios.png +0 -0
  78. package/core/workspace-app/docs/screenshots/readme/test-steps.png +0 -0
@@ -1,6 +1,6 @@
1
1
  # Releases
2
2
 
3
- Every package is versioned together: `@webship.co/workspace` (the core) and every `@webship.co/workspace-<name>`. Versions so far: 1.0.0-alpha1 (the first publish), 1.0.0-beta1, 1.0.0-beta2, 1.0.0-rc1, 1.0.0, 1.0.1 (the core and the git, ide and graph plugins only), 1.0.2, 1.0.3 (the core only).
3
+ Every package is versioned together: `@webship.co/workspace` (the core) and every `@webship.co/workspace-<name>`. Versions so far: 1.0.0-alpha1 (the first publish), 1.0.0-beta1, 1.0.0-beta2, 1.0.0-rc1, 1.0.0, 1.0.1 (the core and the git, ide and graph plugins only), 1.0.2, 1.0.3, 1.0.4 and 1.0.5 (the core only).
4
4
 
5
5
  ## How a change lands
6
6
 
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env bash
2
2
 
3
3
  # The Website project template (drupal/website) is installed by the Webship
4
- # installer, which offers the three Webship site templates. Pick one.
4
+ # installer, which offers the five site templates the project ships. Pick one.
5
5
 
6
6
  argparse "$@" <<EOF || exit 1
7
7
  parser.add_argument('PROJECT_NAME',
8
8
  help='The name of the project. It becomes https://<PROJECT_NAME>.ddev.site')
9
9
  parser.add_argument('-t', '--template',
10
10
  default="website_starter",
11
- choices=['website_starter', 'webship_starter', 'webship_portal'],
11
+ choices=['website_starter', 'webship_starter', 'webship_portal', 'webapi_starter', 'webships_starter'],
12
12
  help='The site template the Webship installer installs [default %(default)s].')
13
13
  parser.add_argument('-a', '--add-users',
14
14
  action='store_true',
@@ -8,6 +8,44 @@ function composer_install_retry() {
8
8
  ddev composer install --no-interaction ;
9
9
  }
10
10
 
11
+ # composer create-project in the current (empty) project folder, for every build family:
12
+ # composer_create_project <template>:<version> [more create-project options]
13
+ #
14
+ # A release line may need two things before its first install, or composer cannot resolve it at all:
15
+ # site_composer_requires root requirements, e.g. an alias. a Drupal 10 line may ask for a package
16
+ # that moved to Drupal 11, so the line aliases the last Drupal 10 release.
17
+ # site_patches_ignore "<package> <patch-url>" pairs that webship/patches declares and the release
18
+ # already contains, so applying the patch fails (patches-ignore, added to composer.json).
19
+ # Both are declared by the builder as bash arrays. A builder that declares neither installs in one step as before.
20
+ # Returns 1 when the codebase could not be installed; the caller says which build stops and where.
21
+ function composer_create_project() {
22
+ local project_template="$1" ; shift ;
23
+ site_requires=("${site_composer_requires[@]}") ;
24
+ site_ignores=("${site_patches_ignore[@]}") ;
25
+ if [ -n "${site_requires[0]:-}${site_ignores[0]:-}" ] ; then
26
+ ddev composer create-project ${project_template} . --no-interaction --no-install --no-scripts "$@" || return 1 ;
27
+ if [ -n "${site_requires[0]:-}" ] ; then
28
+ ddev composer require "${site_requires[@]}" --no-update --no-interaction || return 1 ;
29
+ fi
30
+ if [ -n "${site_ignores[0]:-}" ] ; then
31
+ patches_ignore_json=$(python3 -c 'import sys, json; d = {}; [d.setdefault("webship/patches", {}).setdefault(a.split(" ", 1)[0], []).append(a.split(" ", 1)[1]) for a in sys.argv[1:]]; print(json.dumps(d))' "${site_ignores[@]}") ;
32
+ ddev composer config --json extra.patches-ignore "${patches_ignore_json}" || return 1 ;
33
+ fi
34
+ if ! composer_install_retry || ! ddev composer run-script post-create-project-cmd ; then
35
+ echo "composer could not install ${project_template}." ;
36
+ return 1 ;
37
+ fi
38
+ elif ! ddev composer create-project ${project_template} . --no-interaction "$@" ; then
39
+ # When the project reached the install step, composer.json is already in place: install once more.
40
+ if [ -f composer.json ] && composer_install_retry && ddev composer run-script post-create-project-cmd ; then
41
+ echo "composer create-project needed a second install, which worked." ;
42
+ else
43
+ return 1 ;
44
+ fi
45
+ fi
46
+ return 0 ;
47
+ }
48
+
11
49
  function build_distribution() {
12
50
  # Include distribution functions.
13
51
  source ${WORKSPACE_SCRIPTS}/functions/fun-distribution-${distribution_name}.sh || exit 1 ;
@@ -79,50 +117,21 @@ function build_distribution() {
79
117
 
80
118
  # A failed create-project (e.g. an upstream patch no longer applying) must stop the
81
119
  # build here — every step after this assumes a real vendor/autoload.php exists.
82
- #
83
- # A release line may need two things before its first install, or composer cannot resolve it at all:
84
- # site_composer_requires root requirements, e.g. an alias. a Drupal 10 line may ask for a package
85
- # that moved to Drupal 11, so the line aliases the last Drupal 10 release.
86
- # site_patches_ignore "<package> <patch-url>" pairs that webship/patches declares and the release
87
- # already contains, so applying the patch fails (patches-ignore, added to composer.json).
88
- # Both are declared by the builder as bash arrays. A builder that declares neither installs in one step as before.
89
- site_requires=("${site_composer_requires[@]}") ;
90
- site_ignores=("${site_patches_ignore[@]}") ;
91
- if [ -n "${site_requires[0]:-}${site_ignores[0]:-}" ] ; then
92
- if ! ddev composer create-project ${distribution_project_template}:${site_version} . --no-interaction --no-install --no-scripts ${dev_stability:-} ; then
93
- echo "composer create-project failed for ${distribution_project_template}:${site_version} — aborting, not installing." ;
94
- cd ${WORKSPACE_ROOT}/${doc_name};
95
- return 1 ;
96
- fi
97
- if [ -n "${site_requires[0]:-}" ] ; then
98
- ddev composer require "${site_requires[@]}" --no-update --no-interaction || { cd ${WORKSPACE_ROOT}/${doc_name}; return 1 ; } ;
99
- fi
100
- if [ -n "${site_ignores[0]:-}" ] ; then
101
- patches_ignore_json=$(python3 -c 'import sys, json; d = {}; [d.setdefault("webship/patches", {}).setdefault(a.split(" ", 1)[0], []).append(a.split(" ", 1)[1]) for a in sys.argv[1:]]; print(json.dumps(d))' "${site_ignores[@]}") ;
102
- ddev composer config --json extra.patches-ignore "${patches_ignore_json}" || { cd ${WORKSPACE_ROOT}/${doc_name}; return 1 ; } ;
103
- fi
104
- if ! composer_install_retry || ! ddev composer run-script post-create-project-cmd ; then
105
- echo "composer could not install ${distribution_project_template}:${site_version} — aborting, not installing." ;
106
- cd ${WORKSPACE_ROOT}/${doc_name};
107
- return 1 ;
108
- fi
109
- elif ! ddev composer create-project ${distribution_project_template}:${site_version} . --no-interaction ${dev_stability:-} ; then
110
- # When the project reached the install step, composer.json is already in place: install once more.
111
- if [ -f composer.json ] && composer_install_retry && ddev composer run-script post-create-project-cmd ; then
112
- echo "composer create-project needed a second install, which worked." ;
113
- else
114
- echo "composer create-project failed for ${distribution_project_template}:${site_version} — aborting, not installing." ;
115
- cd ${WORKSPACE_ROOT}/${doc_name};
116
- return 1 ;
117
- fi
120
+ if ! composer_create_project ${distribution_project_template}:${site_version} ${dev_stability:-} ; then
121
+ echo "composer create-project failed for ${distribution_project_template}:${site_version} — aborting, not installing." ;
122
+ cd ${WORKSPACE_ROOT}/${doc_name};
123
+ return 1 ;
118
124
  fi
119
125
 
120
126
  # Some project templates ship their own .ddev config that
121
127
  # changes the docroot/database/webimage packages after our initial ddev
122
128
  # config. Re-align: keep the database type we already provisioned, drop
123
- # optional apt extras (avoids third-party repo key failures), keep our Node.js, and restart
124
- # so the webserver serves the template's real docroot.
125
- ddev config --database=mariadb:${provisioned_db_type} --webimage-extra-packages="" --nodejs-version=$(ddev_nodejs_version) ;
129
+ # optional apt extras (avoids third-party repo key failures), keep our Node.js and the PHP
130
+ # this build asked for (--php-version or the builder's site_php_version: composer resolved the
131
+ # codebase on it, so the template's own php_version would stop drush on the platform check),
132
+ # and restart so the webserver serves the template's real docroot.
133
+ ddev config --database=mariadb:${provisioned_db_type} --webimage-extra-packages="" \
134
+ ${php_version:+--php-version=${php_version}} --nodejs-version=$(ddev_nodejs_version) ;
126
135
  ddev_telemetry_off ;
127
136
  ddev_up restart ;
128
137
 
@@ -56,8 +56,9 @@ function webships_create_project() {
56
56
  # the installer finds them in recipes/ without Composer unpacking them.
57
57
  ddev composer config --json --merge extra.drupal-recipe-unpack.ignore '["drupal/webships_starter", "drupal/webapi_starter"]' ;
58
58
  # The project template ships its own .ddev/config.yaml, which pins an older Node.js:
59
- # re-align to the workspace's Node.js (24, like Drupal core) and restart on it.
60
- ddev config --nodejs-version=$(ddev_nodejs_version) --webimage-extra-packages="" ;
59
+ # re-align to the workspace's Node.js (24, like Drupal core) and to the PHP this build asked
60
+ # for, and restart on them.
61
+ ddev config --php-version=${PHP_VERSION:-${webships_php_version}} --nodejs-version=$(ddev_nodejs_version) --webimage-extra-packages="" ;
61
62
  ddev restart ;
62
63
  }
63
64
 
@@ -29,7 +29,10 @@ function website_create_project() {
29
29
  ddev config --project-type=drupal --docroot=${distribution_webroot} --php-version=${PHP_VERSION:-${website_php_version}} --nodejs-version=$(ddev_nodejs_version) --project-name=${PROJECT_NAME} --auto ;
30
30
  ddev start -y ;
31
31
 
32
- if ! ddev composer create-project "$1" . --no-interaction ; then
32
+ # composer_create_project (fun-build-distribution.sh) honours the builder's site_composer_requires
33
+ # and site_patches_ignore, so a requirement or a patch of the template that no longer resolves or
34
+ # applies can be worked around from the builder, as in build_distribution.
35
+ if ! composer_create_project "$1" ; then
33
36
  echo "composer create-project failed for $1 — aborting, nothing was installed." ;
34
37
  cd ${WORKSPACE_ROOT}/${doc_name};
35
38
  return 1 ;
@@ -39,9 +42,11 @@ function website_create_project() {
39
42
  ddev composer config minimum-stability dev ;
40
43
  ddev composer config prefer-stable true ;
41
44
 
42
- # Keep the site templates as recipes, like drupal/website does: unpacking them
43
- # into the root composer.json fails on aliased dev branches (the pending fixes).
44
- ddev composer config --json --merge extra.drupal-recipe-unpack.ignore '["drupal/website_starter", "drupal/webship_starter", "drupal/webship_portal"]' ;
45
+ # The project template ships its own .ddev/config.yaml, which replaces the one written above and
46
+ # pins an older Node.js: re-align to the workspace's Node.js (24, like Drupal core) and to the PHP
47
+ # version this build asked for, and restart on them.
48
+ ddev config --php-version=${PHP_VERSION:-${website_php_version}} --nodejs-version=$(ddev_nodejs_version) ;
49
+ ddev restart ;
45
50
  }
46
51
 
47
52
  # -----------------------------------------------------------------------------
@@ -121,11 +126,11 @@ function build_website_project() {
121
126
 
122
127
  website_create_project "${distribution_project_template}:${site_version}" || return 1 ;
123
128
 
124
- # The Website project requires the Webship installer and the three site
129
+ # The Website project requires the Webship installer and its five site
125
130
  # templates. Add whatever the published template does not require yet.
126
131
  local require_block missing=() ;
127
132
  require_block=$(sed -n '/"require": {/,/}/p' composer.json) ;
128
- for package in "drupal/webship:12.0.x-dev" "drupal/website_starter:1.0.x-dev" "drupal/webship_starter:1.0.x-dev" "drupal/webship_portal:1.0.x-dev" ; do
133
+ for package in "drupal/webship:12.0.x-dev" "drupal/website_starter:1.0.x-dev" "drupal/webship_starter:1.0.x-dev" "drupal/webship_portal:1.0.x-dev" "drupal/webapi_starter:2.0.x-dev" "drupal/webships_starter:2.0.x-dev" ; do
129
134
  # The installer is drupal/webship or webship/webship; the pending fixes pin the latter.
130
135
  if [ "${package%%:*}" == "drupal/webship" ] && { [ "${WITH_PENDING_FIXES}" == 'yes' ] || echo "${require_block}" | grep -q '"webship/webship"' ; } ; then
131
136
  continue ;
@@ -136,7 +141,7 @@ function build_website_project() {
136
141
  fi
137
142
  done
138
143
 
139
- # The codebase carries all three site templates, so it needs every pending fix.
144
+ # The codebase carries every site template, so it needs every pending fix.
140
145
  website_add_pending_fixes webship $(website_pending_fixes_for webship_portal) ;
141
146
 
142
147
  # Stage everything, then one full update, so no pin is held back by the lock file.
@@ -176,6 +181,13 @@ function build_website_site_template() {
176
181
 
177
182
  website_add_pending_fixes $(website_pending_fixes_for ${site_template_name}) ;
178
183
 
184
+ # Keep the site templates as recipes, like drupal/website does: unpacking them into the root
185
+ # composer.json fails on aliased dev branches (the pending fixes). Only here: the recommended
186
+ # project has no such list, and this runs before the require that writes the lock file.
187
+ # drupal/website carries its own list; merging into it after its lock file was written left
188
+ # duplicates and a lock file that `composer validate --check-lock` calls out of date.
189
+ ddev composer config --json --merge extra.drupal-recipe-unpack.ignore '["drupal/website_starter", "drupal/webship_starter", "drupal/webship_portal"]' ;
190
+
179
191
  if ! ddev composer require -W --no-interaction "drush/drush:^13" "${site_template_package}:${site_version}" ; then
180
192
  echo "composer require ${site_template_package}:${site_version} failed — aborting before the install." ;
181
193
  cd ${WORKSPACE_ROOT}/${doc_name};
@@ -11,9 +11,19 @@ function enable_cucumber_extra_components() {
11
11
  echo "Enable Cucumber extra components.";
12
12
  }
13
13
 
14
- # The default set of users `add_users` creates for Cucumber (--add-users).
14
+ # The six work roles of Cucumber exist only once their role modules are on, and a
15
+ # `drush site:install cucumber` build leaves them off. `add_users` runs this before it
16
+ # creates the accounts, so each one can be given its role on a fresh build.
17
+ function prepare_cucumber_users() {
18
+ echo "Enable the Cucumber role modules of the default users.";
19
+ ddev drush pm:enable --yes cucumber_user_role_developer cucumber_user_role_tester cucumber_user_role_analyst \
20
+ cucumber_user_role_designer cucumber_user_role_coordinator cucumber_user_role_product_owner ;
21
+ }
22
+
23
+ # The default set of users `add_users` creates for Cucumber (--add-users):
24
+ # one account per work role, and one with the Admin role of the profile.
15
25
  function set_cucumber_users() {
16
- users=(authenticated developer tester analyst coordinator product_owner administrator);
26
+ users=(authenticated developer tester analyst designer coordinator product_owner administrator);
17
27
  user_authenticated_name="Authenticated user";
18
28
  user_authenticated_mail="test.authenticated@webship.org";
19
29
  user_authenticated_password="dD.123123ddd";
@@ -30,6 +40,10 @@ function set_cucumber_users() {
30
40
  user_analyst_mail="test.analyst@webship.org";
31
41
  user_analyst_password="dD.123123ddd";
32
42
  user_analyst_role="analyst";
43
+ user_designer_name="Designer";
44
+ user_designer_mail="test.designer@webship.org";
45
+ user_designer_password="dD.123123ddd";
46
+ user_designer_role="designer";
33
47
  user_coordinator_name="Coordinator";
34
48
  user_coordinator_mail="test.coordinator@webship.org";
35
49
  user_coordinator_password="dD.123123ddd";
@@ -41,5 +55,6 @@ function set_cucumber_users() {
41
55
  user_administrator_name="Administrator";
42
56
  user_administrator_mail="test.administrator@webship.org";
43
57
  user_administrator_password="dD.123123ddd";
44
- user_administrator_role="administrator";
58
+ # Cucumber has no `administrator` role: its administration role is Admin (`admin`).
59
+ user_administrator_role="admin";
45
60
  }
@@ -21,8 +21,10 @@ function set_webship_users() {
21
21
  user_content_editor_mail="test.content_editor@webship.org";
22
22
  user_content_editor_password="dD.123123ddd";
23
23
  user_content_editor_role="content_editor";
24
- user_administrator_name="Webmaster";
25
- user_administrator_mail="test.Webmaster@webship.org";
24
+ # Not "Webmaster": the installer's own account is `webmaster` (account.name in settings.yml), and
25
+ # user names are case-insensitive, so that account could never be created.
26
+ user_administrator_name="Administrator";
27
+ user_administrator_mail="test.administrator@webship.org";
26
28
  user_administrator_password="dD.123123ddd";
27
29
  user_administrator_role="administrator";
28
30
  }
@@ -12,8 +12,10 @@ function set_website_users() {
12
12
  user_content_editor_mail="test.content_editor@webship.org";
13
13
  user_content_editor_password="dD.123123ddd";
14
14
  user_content_editor_role="content_editor";
15
- user_administrator_name="Webmaster";
16
- user_administrator_mail="test.webmaster@webship.org";
15
+ # Not "Webmaster": the installer's own account is `webmaster` (account.name in settings.yml), and
16
+ # user names are case-insensitive, so that account could never be created.
17
+ user_administrator_name="Administrator";
18
+ user_administrator_mail="test.administrator@webship.org";
17
19
  user_administrator_password="dD.123123ddd";
18
20
  user_administrator_role="administrator";
19
21
  }
@@ -32,6 +32,17 @@ function add_users () {
32
32
 
33
33
  cd ${WORKSPACE_ROOT}/${doc_name}/${PROJECT_NAME}/${distribution_webroot}/;
34
34
 
35
+ # A distribution whose roles only exist once something is switched on (Cucumber's work roles
36
+ # come from their role modules) declares prepare_<distribution>_users next to its list. It runs
37
+ # here, so the accounts get their roles on a fresh build and from cmd-tools-add-users.sh alike.
38
+ if declare -f prepare_${distribution_name}_users > /dev/null ; then
39
+ if ! prepare_${distribution_name}_users ; then
40
+ echo "The roles of the default users could not be prepared — no user was added." ;
41
+ cd ${WORKSPACE_ROOT}/${doc_name};
42
+ return 1 ;
43
+ fi
44
+ fi
45
+
35
46
  for user in ${users[@]}
36
47
  do
37
48
  user_name="user_${user}_name";
@@ -8,7 +8,9 @@
8
8
  # prints usage and exits before any real work),
9
9
  # which exercises bootstrap.sh, parse_yaml, the
10
10
  # workspace + distribution configs and arg-*.sh
11
- # 3. workspace-name — builder scripts must carry the "# workspace-name:"
11
+ # 3. the last call — the function a script ends by calling exists, and a builder that
12
+ # ends with build_distribution has its install function
13
+ # 4. workspace-name — builder scripts must carry the "# workspace-name:"
12
14
  # header the dashboard shows in Build dropdowns
13
15
  #
14
16
  # Exit code: number of failing scripts (0 = all green).
@@ -78,6 +80,18 @@ for ws in "${workspaces[@]}" commands core/scripts; do
78
80
  done
79
81
  fi
80
82
 
83
+ # 3b. A builder that ends with build_distribution is installed by install_<distribution>_with_drush,
84
+ # which build_distribution calls by name. A builder copied from another family can keep that last
85
+ # line while its own family has no such function (the Website family installs in
86
+ # build_website_project): build_distribution exists, so check 3 passes, and the build stops at the install.
87
+ if [ -z "$err" ] && grep -qE '^build_distribution ;$' "$f"; then
88
+ dist=$(grep -oE '^distribution_name="[a-z0-9_]+"' "$f" | head -1 | cut -d'"' -f2)
89
+ if [ -n "$dist" ] && ! grep -qE "^(function )?install_${dist}_with_drush ?\(\)" \
90
+ "${WORKSPACE_SCRIPTS}/functions/fun-distribution-${dist}.sh" 2>/dev/null; then
91
+ err="ends with build_distribution, but install_${dist}_with_drush() is not in fun-distribution-${dist}.sh"
92
+ fi
93
+ fi
94
+
81
95
  # 4. Builder scripts must have the human-readable name header.
82
96
  # A builder is cmd-<distribution><version>-project.sh. cmd-tool-*/cmd-tools-* are the
83
97
  # workspace's own tools — projects/cmd-tool-backup-project.sh ends in -project.sh too,
@@ -247,6 +247,7 @@ Some releases change the container image; the notes say so, and then
247
247
  | the hard rules and the operational traps | `docs/workspace-tips-and-rules.md` |
248
248
  | what changed, dated | `worklogs/` |
249
249
 
250
- Every picture in `screenshots/` is used by one of these guides or the top `README.md`, and all of them
251
- were taken on 22 October 2026 at 1920×1080, in light mode (the README's dark copies aside). A pull
252
- request carries its own pictures on its branch.
250
+ The pictures in `screenshots/` are used by these guides; `assistant/` and `readme/` hold spare ones of
251
+ the assistant's avatar and of the pages in light and dark. All of them were taken on 13 November 2026
252
+ on a Webship Workspace with demo content, at 1920×1080 (the home page is taller, to show every card),
253
+ in light mode unless the name says dark. A pull request carries its own pictures on its branch.
@@ -399,7 +399,7 @@ Claude ends the way it would if you typed it, and the conversation is kept. When
399
399
  project or folder that has one, the same menu offers **Resume Claude Code**, which resumes the last conversation
400
400
  with `claude --resume` in that folder.
401
401
 
402
- ![The SCREENS menu on a card: its sessions, Resume Claude Code, New screen here and Claude Code here](screenshots/screens-menu.png)
402
+ ![The SCREENS menu on a card: its session, New screen here, Claude Code here and All screens](screenshots/screens-menu.png)
403
403
 
404
404
  ### Watch something long
405
405
 
@@ -459,7 +459,7 @@ Pick the **Provider** first: OpenAI, Google Gemini, xAI, Stability AI or Claude
459
459
  quality fields change with it. A provider with no key on this machine is listed but greyed out, with the key
460
460
  to set (`ai_providers.<provider>.api_key` in `core/config/settings.yml`).
461
461
 
462
- ![The provider select: the ones without a key say which key to set](screenshots/images/providers.png)
462
+ ![Generate with AI: the form, with Claude Code CLI as the provider](screenshots/images/providers.png)
463
463
 
464
464
  **Claude Code** needs no key: it uses your own Claude Code login, so there is no API charge. It cannot paint
465
465
  a photo, so it draws a flat illustration as an SVG, and the dashboard turns it into a PNG as well; you keep
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "workspace-dashboard",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "The Webship Workspace dashboard: a lean core (workspaces, builders, settings, the assistant shell, the plugin system) that every feature joins as a @webship.co/workspace-<name> plugin — required with npm, then activated, installed and configured.",
5
5
  "license": "ISC",
6
6
  "main": "server.js",
@@ -7875,7 +7875,7 @@ Summarise what you changed and how to verify it`,
7875
7875
  // An SVG is a document that can carry script; shown on its own it runs none.
7876
7876
  ...(type === 'image/svg+xml' ? { 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox" } : {}),
7877
7877
  });
7878
- return fs.createReadStream(file).pipe(res);
7878
+ return fs.createReadStream(real).pipe(res);
7879
7879
  }
7880
7880
  const argsMatch = pathname.match(/^\/fragments\/([a-z0-9_-]+)\/builder-args$/);
7881
7881
  if (argsMatch && isValidWorkspace(argsMatch[1])) {
@@ -59,6 +59,50 @@ test('every ddev config of a build pins Node.js 24, and the first one still gets
59
59
  assert.ok(!configs[0].includes('ddev_telemetry_off'), configs[0]);
60
60
  });
61
61
 
62
+ test('the PHP version of the build is set again after create-project, where the template brings its own DDEV config', () => {
63
+ // The template's .ddev/config.yaml replaces the builder's: composer resolved on 8.4, the project ran the
64
+ // template's 8.3, and drush site:install stopped on "Your Composer dependencies require a PHP version".
65
+ for (const declare of ['PHP_VERSION=8.4', 'site_php_version=8.4', 'PHP_VERSION=8.4; site_php_version=8.5']) {
66
+ const { calls, code } = build({ declare });
67
+ assert.strictEqual(code, 0);
68
+ const configs = calls.filter((c) => c.startsWith('config '));
69
+ assert.strictEqual(configs.length, 2, configs.join('\n'));
70
+ for (const c of configs) assert.ok(c.includes('--php-version=8.4'), `${declare}: ${c}`);
71
+ assert.ok(index(calls, 'composer create-project') < calls.indexOf(configs[1]), 'the second one follows create-project');
72
+ assert.ok(configs[1].includes('--database=mariadb:11.8') && configs[1].includes('--nodejs-version=24'), configs[1]);
73
+ }
74
+ // Nobody asked for a version: DDEV's default before, the template's own after, as it was.
75
+ const plain = build();
76
+ for (const c of plain.calls.filter((x) => x.startsWith('config '))) assert.ok(!c.includes('--php-version'), c);
77
+ });
78
+
79
+ test('the Webships builder sets its PHP version again after create-project too', () => {
80
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ws-webships-'));
81
+ const log = path.join(dir, 'ddev.log');
82
+ fs.mkdirSync(path.join(dir, 'ws'));
83
+ const WEBSHIPS = path.join(path.dirname(FUNCTION), 'fun-build-webships.sh');
84
+ const run = (declare) => {
85
+ fs.rmSync(log, { force: true });
86
+ const script = `
87
+ WORKSPACE_ROOT=${dir}; doc_name=ws; PROJECT_NAME=p1; distribution_webroot=web; ${declare}
88
+ ddev() { printf '%s\\n' "$*" >> ${log}; return 0; }
89
+ drop_database() { :; }
90
+ source ${NODEJS}; source ${WEBSHIPS}
91
+ webships_create_project drupal/stub:1.0.x-dev; echo "exit=$?"
92
+ `;
93
+ const out = spawnSync('bash', ['-c', script], { cwd: dir, encoding: 'utf8' }).stdout;
94
+ assert.match(out, /exit=0/);
95
+ return fs.readFileSync(log, 'utf8').trim().split('\n');
96
+ };
97
+ for (const [declare, version] of [['PHP_VERSION=8.5', '8.5'], ['', '8.4']]) {
98
+ const calls = run(declare);
99
+ const configs = calls.filter((c) => c.startsWith('config '));
100
+ assert.strictEqual(configs.length, 2, configs.join('\n'));
101
+ for (const c of configs) assert.ok(c.includes(`--php-version=${version}`) && c.includes('--nodejs-version=24'), c);
102
+ assert.ok(index(calls, 'composer create-project') < calls.indexOf(configs[1]));
103
+ }
104
+ });
105
+
62
106
  test('a builder that declares site_nodejs_version gets that version instead', () => {
63
107
  const { calls } = build({ declare: 'site_nodejs_version=22' });
64
108
  const configs = calls.filter((c) => c.startsWith('config '));
@@ -0,0 +1,116 @@
1
+ /**
2
+ * What the Website builders run, in what order.
3
+ *
4
+ * ddev and the helpers around it are stubbed, so the test reads the calls rather than building a site.
5
+ * build_website_project (drupal/website, installed by the Webship installer) had no way to add a root
6
+ * requirement or to ignore a patch before the first install, the way build_distribution has.
7
+ */
8
+
9
+ const assert = require('node:assert');
10
+ const { test } = require('node:test');
11
+ const fs = require('fs');
12
+ const os = require('os');
13
+ const path = require('path');
14
+ const { spawnSync } = require('child_process');
15
+
16
+ const FUNCTIONS = path.join(__dirname, '..', '..', '..', 'scripts', 'functions');
17
+
18
+ // Runs a Website build function with a fake ddev that records its arguments, one call per line.
19
+ function build({ declare = '', run = 'build_website_project' } = {}) {
20
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ws-website-'));
21
+ const log = path.join(dir, 'ddev.log');
22
+ fs.mkdirSync(path.join(dir, 'ws'));
23
+ const script = `
24
+ WORKSPACE_ROOT=${dir}; doc_name=ws; PROJECT_NAME=p1
25
+ distribution_name=website; distribution_title=Website; distribution_webroot=web; distribution_project_template=drupal/website; site_version=12.0.x-dev
26
+ account_name=admin; account_pass=pass; account_mail=admin@example.com
27
+ ${declare}
28
+ ddev() { printf '%s\\n' "$*" >> ${log}; return 0; }
29
+ drop_database() { :; }; set_default_settings() { :; }; drush_set_debug_on() { :; }; drush_cr() { :; }; add_users() { :; }
30
+ source ${FUNCTIONS}/fun-build-distribution.sh; source ${FUNCTIONS}/fun-ddev-nodejs.sh; source ${FUNCTIONS}/fun-build-website.sh
31
+ ${run}; echo "exit=$?"
32
+ `;
33
+ const res = spawnSync('bash', ['-c', script], { cwd: dir, encoding: 'utf8' });
34
+ const calls = fs.existsSync(log) ? fs.readFileSync(log, 'utf8').trim().split('\n') : [];
35
+ return { calls, out: res.stdout, code: Number((res.stdout.match(/exit=(\d+)/) || [])[1]) };
36
+ }
37
+
38
+ const index = (calls, needle) => calls.findIndex((c) => c.includes(needle));
39
+
40
+ test('a Website builder that declares nothing creates the project in one step and installs with the chosen template', () => {
41
+ const { calls, code } = build({ declare: 'TEMPLATE=webship_portal' });
42
+ assert.strictEqual(code, 0);
43
+ const create = calls.find((c) => c.startsWith('composer create-project'));
44
+ assert.strictEqual(create, 'composer create-project drupal/website:12.0.x-dev . --no-interaction');
45
+ assert.ok(index(calls, 'patches-ignore') < 0 && index(calls, 'composer install') < 0);
46
+ const install = calls.find((c) => c.startsWith('drush site:install'));
47
+ assert.ok(install.includes('site:install webship') && install.includes('installer_site_template_form.add_ons=webship_portal'), install);
48
+ });
49
+
50
+ test('declared requirements and patches to ignore are applied between create-project and the first install', () => {
51
+ const { calls, code } = build({
52
+ declare: `site_composer_requires=("drupal/stub:1.2.3 as 1.2.x-dev"); site_patches_ignore=("drupal/pp https://x.test/a.patch")`,
53
+ });
54
+ assert.strictEqual(code, 0);
55
+ const create = index(calls, 'composer create-project');
56
+ assert.ok(calls[create].includes('--no-install') && calls[create].includes('--no-scripts'), calls[create]);
57
+ const order = ['composer create-project', 'composer require drupal/stub', 'composer config --json extra.patches-ignore', 'composer install', 'composer run-script post-create-project-cmd', 'drush site:install']
58
+ .map((n) => index(calls, n));
59
+ assert.ok(order.every((i) => i >= 0), calls.join(' | '));
60
+ assert.deepStrictEqual(order, [...order].sort((a, b) => a - b), `in order: ${calls.join(' | ')}`);
61
+ assert.strictEqual(calls[order[2]], 'composer config --json extra.patches-ignore {"webship/patches": {"drupal/pp": ["https://x.test/a.patch"]}}');
62
+ });
63
+
64
+ test('a site template build on the recommended project takes the same declarations', () => {
65
+ const { calls, code } = build({
66
+ declare: `site_template_name=website_starter; site_template_title="Website Starter"; site_template_package=drupal/website_starter; site_version=1.0.x-dev; site_patches_ignore=("drupal/pp https://x.test/a.patch")`,
67
+ run: 'build_website_site_template',
68
+ });
69
+ assert.strictEqual(code, 0);
70
+ const create = calls.find((c) => c.startsWith('composer create-project'));
71
+ assert.ok(create.startsWith('composer create-project drupal/recommended-project:') && create.includes('--no-install'), create);
72
+ assert.ok(index(calls, 'extra.patches-ignore') > 0);
73
+ });
74
+
75
+ test('the Node.js and PHP of the build are set again after create-project, where the template brings its own DDEV config', () => {
76
+ const { calls, code } = build({ declare: 'PHP_VERSION=8.5' });
77
+ assert.strictEqual(code, 0);
78
+ const create = index(calls, 'composer create-project');
79
+ const configs = calls.map((c, i) => [c, i]).filter(([c]) => c.startsWith('config '));
80
+ assert.strictEqual(configs.length, 2, configs.map(([c]) => c).join('\n'));
81
+ assert.ok(configs[0][1] < create && configs[1][1] > create, 'one before create-project, one after');
82
+ for (const [c] of configs) assert.ok(c.includes('--nodejs-version=24') && c.includes('--php-version=8.5'), c);
83
+ assert.ok(index(calls, 'restart') > configs[1][1], 'and the project restarts on them');
84
+ assert.ok(index(calls, 'restart') < index(calls, 'drush site:install'));
85
+
86
+ const declared = build({ declare: 'site_nodejs_version=22' });
87
+ for (const c of declared.calls.filter((x) => x.startsWith('config '))) assert.ok(c.includes('--nodejs-version=22') && c.includes('--php-version=8.4'), c);
88
+ });
89
+
90
+ test('the Website project keeps the recipe-unpack ignore list its template ships, and a site template build sets one before the lock file', () => {
91
+ const project = build();
92
+ assert.strictEqual(project.code, 0);
93
+ assert.ok(index(project.calls, 'drupal-recipe-unpack') < 0, 'composer.json is not changed after the lock file was written');
94
+
95
+ const { calls, code } = build({
96
+ declare: 'site_template_name=website_starter; site_template_title="Website Starter"; site_template_package=drupal/website_starter; site_version=1.0.x-dev',
97
+ run: 'build_website_site_template',
98
+ });
99
+ assert.strictEqual(code, 0);
100
+ const ignore = index(calls, 'drupal-recipe-unpack');
101
+ assert.ok(ignore > 0 && calls[ignore].includes('"drupal/website_starter"'), calls[ignore]);
102
+ assert.ok(ignore < index(calls, 'composer require -W'), 'before the require that writes the lock file');
103
+ });
104
+
105
+ test('--template offers the five site templates the Website project ships, and each one reaches the installer', () => {
106
+ const args = fs.readFileSync(path.join(FUNCTIONS, '..', 'args', 'arg-website.sh'), 'utf8');
107
+ const choices = args.match(/'--template',[\s\S]*?choices=\[([^\]]*)\]/)[1].split(',').map((c) => c.trim().replace(/'/g, ''));
108
+ assert.deepStrictEqual(choices, ['website_starter', 'webship_starter', 'webship_portal', 'webapi_starter', 'webships_starter']);
109
+ for (const template of choices) {
110
+ const { calls, code } = build({ declare: `TEMPLATE=${template}` });
111
+ assert.strictEqual(code, 0);
112
+ assert.ok(calls.find((c) => c.startsWith('drush site:install')).includes(`installer_site_template_form.add_ons=${template}`), template);
113
+ // No composer.json in the stub project: the builder adds what the template does not require, all five.
114
+ assert.ok(calls.find((c) => c.startsWith('composer require')).includes(`drupal/${template}:`), template);
115
+ }
116
+ });
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The default accounts of every distribution (`--add-users`, cmd-tools-add-users.sh) can be created.
3
+ *
4
+ * Each list is read from set_<distribution>_users in fun-distribution-<distribution>.sh by bash itself.
5
+ * The Website and Webship lists had a "Webmaster" account: the installer's own account is `webmaster`,
6
+ * user names are case-insensitive, so `drush user:create` answered "There is already a user account
7
+ * with the name Webmaster" and the account never existed.
8
+ */
9
+
10
+ const assert = require('node:assert');
11
+ const { test } = require('node:test');
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const { spawnSync } = require('child_process');
15
+
16
+ const SCRIPTS = path.join(__dirname, '..', '..', '..', 'scripts');
17
+ const FUNCTIONS = path.join(SCRIPTS, 'functions');
18
+ const SETTINGS = path.join(SCRIPTS, '..', 'config', 'settings.template.yml');
19
+
20
+ /** The accounts of one distribution: [{ key, name, mail, password, role }]. */
21
+ function usersOf(distribution) {
22
+ const script = `
23
+ ddev() { :; }
24
+ source ${FUNCTIONS}/fun-distribution-${distribution}.sh
25
+ set_${distribution}_users
26
+ for user in "\${users[@]}"; do
27
+ n="user_\${user}_name"; m="user_\${user}_mail"; p="user_\${user}_password"; r="user_\${user}_role"
28
+ printf '%s|%s|%s|%s|%s\\n' "\${user}" "\${!n}" "\${!m}" "\${!p}" "\${!r}"
29
+ done`;
30
+ const run = spawnSync('bash', ['-c', script], { encoding: 'utf8' });
31
+ return run.stdout.trim().split('\n').filter(Boolean).map((line) => {
32
+ const [key, name, mail, password, role] = line.split('|');
33
+ return { key, name, mail, password, role };
34
+ });
35
+ }
36
+
37
+ const distributions = fs.readdirSync(FUNCTIONS)
38
+ .map((f) => (f.match(/^fun-distribution-([a-z0-9_]+)\.sh$/) || [])[1])
39
+ .filter((d) => d && fs.readFileSync(path.join(FUNCTIONS, `fun-distribution-${d}.sh`), 'utf8').includes(`set_${d}_users()`));
40
+
41
+ // The account the installer creates: account.name in settings.yml.
42
+ const installer = (fs.readFileSync(SETTINGS, 'utf8').match(/^account:\n(?:[ \t]+.*\n)*?[ \t]+name:[ \t]*(\S+)/m) || [])[1];
43
+
44
+ test('the installer account and the distributions with a user list are found', () => {
45
+ assert.strictEqual(installer, 'webmaster');
46
+ for (const d of ['drupal', 'drupal_cms', 'webship', 'website', 'webships', 'cucumber']) assert.ok(distributions.includes(d), d);
47
+ });
48
+
49
+ for (const distribution of distributions) {
50
+ test(`${distribution}: every default account is complete, and no name is taken`, () => {
51
+ const users = usersOf(distribution);
52
+ assert.ok(users.length > 0, 'the list is not empty');
53
+ for (const u of users) {
54
+ assert.ok(u.name && u.mail && u.password && u.role, `${u.key} misses a value: ${JSON.stringify(u)}`);
55
+ assert.notStrictEqual(u.name.toLowerCase(), installer.toLowerCase(), `"${u.name}" is the installer's account: user names are case-insensitive`);
56
+ }
57
+ const names = users.map((u) => u.name.toLowerCase());
58
+ assert.strictEqual(new Set(names).size, names.length, `two accounts share a name: ${names.join(', ')}`);
59
+ const mails = users.map((u) => u.mail.toLowerCase());
60
+ assert.strictEqual(new Set(mails).size, mails.length, `two accounts share a mail: ${mails.join(', ')}`);
61
+ });
62
+ }
63
+
64
+ /** The ddev calls of add_users for one distribution, with ddev stubbed. */
65
+ function addUsers(distribution, { enableFails = false } = {}) {
66
+ const os = require('os');
67
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ws-users-'));
68
+ const log = path.join(dir, 'ddev.log');
69
+ fs.mkdirSync(path.join(dir, 'ws', 'p1', 'web'), { recursive: true });
70
+ const script = `
71
+ WORKSPACE_SCRIPTS=${SCRIPTS}; WORKSPACE_ROOT=${dir}; doc_name=ws; PROJECT_NAME=p1; distribution_name=${distribution}; distribution_webroot=web
72
+ ddev() { printf '%s\\n' "$*" >> ${log}; if [ "${enableFails ? 'yes' : 'no'}" = yes ] && [ "$1 $2" = 'drush pm:enable' ]; then return 1; fi; return 0; }
73
+ source ${FUNCTIONS}/fun-users.sh
74
+ add_drush() { :; }
75
+ add_users p1 ${distribution} > /dev/null; echo "exit=$?"
76
+ `;
77
+ const run = spawnSync('bash', ['-c', script], { encoding: 'utf8' });
78
+ const calls = fs.existsSync(log) ? fs.readFileSync(log, 'utf8').trim().split('\n') : [];
79
+ return { calls, code: Number((run.stdout.match(/exit=(\d+)/) || [])[1]) };
80
+ }
81
+
82
+ test('cucumber: the role modules are enabled before the first account, and every role given exists then', () => {
83
+ const { calls, code } = addUsers('cucumber');
84
+ assert.strictEqual(code, 0);
85
+ const enable = calls.findIndex((c) => c.startsWith('drush pm:enable'));
86
+ const firstUser = calls.findIndex((c) => c.startsWith('drush user:create'));
87
+ assert.ok(enable >= 0 && enable < firstUser, calls.join(' | '));
88
+ // The roles of the profile itself, and the ones the enabled role modules bring.
89
+ const roles = new Set(['admin', 'super_admin', ...[...calls[enable].matchAll(/cucumber_user_role_([a-z_]+)/g)].map((m) => m[1])]);
90
+ const given = calls.filter((c) => c.startsWith('drush user:role:add')).map((c) => c.split(' ')[2]);
91
+ assert.deepStrictEqual(given, ['developer', 'tester', 'analyst', 'designer', 'coordinator', 'product_owner', 'admin']);
92
+ for (const role of given) assert.ok(roles.has(role), `nothing provides the "${role}" role`);
93
+ assert.ok(!given.includes('administrator'), 'Cucumber has no administrator role');
94
+ assert.strictEqual(calls.filter((c) => c.startsWith('drush user:create')).length, 8);
95
+ });
96
+
97
+ test('cucumber: when the role modules cannot be enabled no account is created', () => {
98
+ const { calls, code } = addUsers('cucumber', { enableFails: true });
99
+ assert.strictEqual(code, 1);
100
+ assert.ok(!calls.some((c) => c.startsWith('drush user:create')), calls.join(' | '));
101
+ });
102
+
103
+ test('a distribution with nothing to prepare adds its users as before', () => {
104
+ const { calls, code } = addUsers('website');
105
+ assert.strictEqual(code, 0);
106
+ assert.ok(!calls.some((c) => c.includes('pm:enable')));
107
+ assert.deepStrictEqual(calls.filter((c) => c.startsWith('drush user:create')).map((c) => c.split(' --mail')[0]),
108
+ ['drush user:create Authenticated user', 'drush user:create Content editor', 'drush user:create Administrator']);
109
+ });
@@ -0,0 +1,16 @@
1
+ // The /files/<workspace>/<path> route resolves the file to `real` and must stream that. It streamed
2
+ // `file`, a name the route never defines, so every plain (non-range) request sent headers and no body.
3
+ const assert = require('node:assert');
4
+ const { test } = require('node:test');
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+
8
+ test('the files route streams the file it resolved', () => {
9
+ const src = fs.readFileSync(path.join(__dirname, '..', '..', 'server.js'), 'utf8');
10
+ const start = src.indexOf('filesBrowser.realFileIn(workspaceDir(fileMatch[1]), name)');
11
+ assert.ok(start > 0, 'the files route was not found');
12
+ const route = src.slice(start, src.indexOf('builder-args', start));
13
+ const streams = [...route.matchAll(/fs\.createReadStream\((\w+)/g)].map((m) => m[1]);
14
+ assert.ok(streams.length >= 2, 'expected the range branch and the whole-file branch');
15
+ for (const name of streams) assert.strictEqual(name, 'real', `the route streams "${name}", which it does not define`);
16
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webship.co/workspace",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "Webship Workspace: a container-based development workspace with a web dashboard. The core ships empty; workspace folders, hosting providers, git forges, container runtimes and features are @webship.co/workspace-<name> plugins you add one at a time.",
5
5
  "license": "GPL-2.0-or-later",
6
6
  "repository": {