@webship.co/workspace 1.0.0 → 1.0.1

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.
@@ -205,7 +205,7 @@ it alike), so a lazy menu behaves like an inline one once open. A string return
205
205
  menu, inline. A plugin that must also run on a dashboard older than this form checks
206
206
  `api.capabilities && api.capabilities.has('row-menu-body')` and falls back to the string.
207
207
 
208
- The core's own menus take the same road: DDEV, COMPOSER, Tests and SCREENS are drawn as this shell and
208
+ The core's own menus take the same road: DDEV, COMPOSER, Tests, SCREENS, NODE and More are drawn as this shell and
209
209
  answer at `/fragments/row-menu/<slot>/<ws>/<project>` with no `body` parameter, their items computed for
210
210
  that one project when the menu opens (a page of fifty rows is a third of what it was). A plugin's
211
211
  `body` must still name one of its own fragments; the core slots are not open to one.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "workspace-dashboard",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
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",
@@ -414,7 +414,7 @@ function rowMenuHtml(slot, result, ctx = {}) {
414
414
 
415
415
  /**
416
416
  * The shell of a row menu whose body loads when it opens — the one markup for a plugin's lazy menu and for
417
- * the core's own (DDEV, COMPOSER, Tests, SCREENS): `<span class="act-with-status">` around the
417
+ * the core's own (DDEV, COMPOSER, Tests, SCREENS, NODE, More): `<span class="act-with-status">` around the
418
418
  * `.uk-inline.act-menu.act-menu-<slot>` wrapper, the button (`html`), an empty `uk-dropdown` shell and the
419
419
  * caption. The shell fetches `/fragments/row-menu/<slot>/<ws>/<project><query>` on `menu-opened` (the relay
420
420
  * of UIkit's `beforeshow` in public/ui.js) and swaps the answer in as its inner HTML; `triggers` adds more
@@ -1018,13 +1018,36 @@ function composerMenuBody(key, p, projectPath, isDdev, mode = 'manifest') {
1018
1018
  * left, a package and the package.json scripts on the right. What runs lives in actions/node.js.
1019
1019
  */
1020
1020
  function nodeMenuHtml(key, p, projectPath, isDdev) {
1021
- const { MANAGERS, NODE_GROUPS, NODE_RUN_ACTIONS, NODE_PACKAGE_ACTIONS, NODE_SCRIPT_ROUTE,
1022
- hasPackageJson, nodeInfo, nodeArgsFor, nodeScripts } = require('./actions/node');
1023
- if (!hasPackageJson(projectPath)) return '';
1021
+ const state = nodeMenuState(projectPath);
1022
+ if (!state) return '';
1023
+ // The row carries the button and the caption; the body (nodeMenuBody) arrives from
1024
+ // /fragments/row-menu/node/<ws>/<project> when the menu opens — fifty rows, fifty buttons, no menu (#111).
1025
+ return menuShellHtml('node', {
1026
+ key, project: p, caption: state.caption, dropdownClasses: 'node-dropdown',
1027
+ html: '<button class="uk-button uk-button-default uk-button-small" type="button" title="npm and yarn for this folder"><span uk-icon="icon: nut; ratio: .7"></span> NODE <span uk-icon="icon: chevron-down; ratio: .6"></span></button>',
1028
+ });
1029
+ }
1030
+
1031
+ /** What the NODE caption says: package.json parsed (or not), and which lock files sit beside it. Null without a package.json. */
1032
+ function nodeMenuState(projectPath) {
1033
+ const { hasPackageJson, nodeInfo } = require('./actions/node');
1034
+ if (!hasPackageJson(projectPath)) return null;
1024
1035
  const info = nodeInfo(projectPath);
1025
- const lockFile = { yarn: 'yarn.lock', npm: 'package-lock.json' };
1026
1036
  const caption = !info.valid ? 'bad json'
1027
1037
  : (info.locks.yarn && info.locks.npm ? '2 locks' : (info.locks.yarn ? 'yarn.lock' : (info.locks.npm ? 'npm lock' : 'no lock')));
1038
+ return { info, caption };
1039
+ }
1040
+
1041
+ /**
1042
+ * The NODE menu's body — what used to sit inside the dropdown on every row — for one project, served by
1043
+ * /fragments/row-menu/node/<ws>/<project> when the menu opens. '' for a folder without package.json.
1044
+ */
1045
+ function nodeMenuBody(key, p, projectPath, isDdev) {
1046
+ const { MANAGERS, NODE_GROUPS, NODE_RUN_ACTIONS, NODE_PACKAGE_ACTIONS, NODE_SCRIPT_ROUTE, nodeArgsFor, nodeScripts } = require('./actions/node');
1047
+ const state = nodeMenuState(projectPath);
1048
+ if (!state) return '';
1049
+ const { info } = state;
1050
+ const lockFile = { yarn: 'yarn.lock', npm: 'package-lock.json' };
1028
1051
  const vals = (m, extra = '') => `hx-vals='{"workspace":"${esc(key)}","projectName":"${esc(p)}","manager":"${m}"${extra}}' hx-target="#webship-workspace-output" hx-swap="innerHTML"`;
1029
1052
  const scripts = info.valid ? nodeScripts(info.pkg) : [];
1030
1053
  const panel = (m) => {
@@ -1063,17 +1086,12 @@ function nodeMenuHtml(key, p, projectPath, isDdev) {
1063
1086
  </div></li>`;
1064
1087
  };
1065
1088
  const tabs = MANAGERS.map((m) => `<li><a href="#">${m} <span class="uk-text-meta">· ${info.locks[m] ? 'lock ✓' : 'no lock'}</span></a></li>`).join('');
1066
- return `<span class="act-with-status"><div class="uk-inline act-menu act-menu-node">
1067
- <button class="uk-button uk-button-default uk-button-small" type="button" title="npm and yarn for this folder"><span uk-icon="icon: nut; ratio: .7"></span> NODE <span uk-icon="icon: chevron-down; ratio: .6"></span></button>
1068
- <div uk-dropdown="mode: click; pos: bottom-right" class="node-dropdown">
1069
- <ul class="uk-nav uk-dropdown-nav">
1089
+ return `<ul class="uk-nav uk-dropdown-nav">
1070
1090
  <li class="uk-nav-header">Node · ${esc((info.pkg && typeof info.pkg.name === 'string' && info.pkg.name) || p)}</li>
1071
1091
  <li data-entry="how it runs"><span class="uk-text-meta">${isDdev ? 'Runs as ddev npm / ddev yarn in this project.' : "Not a DDEV project — runs in the dashboard's DDEV container."}${info.pinned ? ` packageManager: ${esc(info.pinned.spec)}.` : ''}${info.nodeModules ? ' node_modules is here.' : ' No node_modules yet.'}${info.valid ? '' : ' package.json is not valid JSON.'}</span></li>
1072
1092
  </ul>
1073
1093
  <ul class="uk-tab node-tabs" data-keep-open uk-tab="active: ${Math.max(0, MANAGERS.indexOf(info.preferred))}">${tabs}</ul>
1074
- <ul class="uk-switcher">${MANAGERS.map(panel).join('')}</ul>
1075
- </div>
1076
- </div><span class="act-status uk-text-meta">${esc(caption)}</span></span>`;
1094
+ <ul class="uk-switcher">${MANAGERS.map(panel).join('')}</ul>`;
1077
1095
  }
1078
1096
 
1079
1097
 
@@ -3285,6 +3303,46 @@ function ddevMenuItemsHtml({ key, p, meta, projectPath, gates, ddev }) {
3285
3303
  * workspace's type must WANT the action (policy — `type:` in its settings file). Read once per page by
3286
3304
  * the rows, and once per request by a menu body served on its own.
3287
3305
  */
3306
+ /**
3307
+ * The More menu's items for one project — the changelog, the recordings, the backups and the remove —
3308
+ * before the Buttons settings arrange them. The row builds them to count what the caption says
3309
+ * ("3 actions") and /fragments/row-menu/more/<ws>/<project> builds them again when the menu opens (#111).
3310
+ * Both verbs of the teardown also live in the DDEV menu, where a DDEV project's teardown belongs. They
3311
+ * stay here too: More is where someone looks for them, and a folder DDEV does not manage has no other home.
3312
+ */
3313
+ function moreMenuItemsHtml({ key, p, gates }) {
3314
+ const { canBackup, canRemove } = gates;
3315
+ const items = [];
3316
+ // What this product shipped, read from the CHANGELOG it already keeps. It belongs to the
3317
+ // row rather than to the rail: a changelog is a fact about one product, and the question
3318
+ // people ask is "what went into 1.0.3 of THIS", standing in front of it in its folder.
3319
+ // The latest version is in the label, so the menu answers the common case unopened.
3320
+ const cl = changelog.changelogFor(key, p);
3321
+ if (cl) {
3322
+ const latest = (changelog.changelogIndex().find((r) => r.workspace === key && r.item === p) || {}).latest || '';
3323
+ items.push(`<li><a href hx-get="/fragments/changelog/${encodeURIComponent(key)}/${encodeURIComponent(p)}"
3324
+ hx-target="#editor-modal-body" hx-swap="innerHTML" uk-toggle="target: #editor-modal"
3325
+ title="Releases, newest first, from ${esc(cl.file)}"><span uk-icon="icon: history; ratio: .7"></span> Changelog${latest ? ` <span class="uk-text-meta">${esc(latest)}</span>` : ''}</a></li>`);
3326
+ }
3327
+ // Recordings about this project: a video, or audio, started from a set of pre-prompts.
3328
+ if (projectMake.WORKSPACES.includes(key)) {
3329
+ const makeLink = (kind, icon, label, hint) => `<li data-entry="${esc(label.toLowerCase())}"><a href hx-get="/fragments/projects/make-form/${encodeURIComponent(key)}/${encodeURIComponent(p)}?kind=${kind}"
3330
+ hx-target="#editor-modal-body" hx-swap="innerHTML" hx-on::after:request="UIkit.modal('#editor-modal').show()"
3331
+ title="${esc(hint)}"><span uk-icon="icon: ${icon}; ratio: .7"></span> ${esc(label)}…</a></li>`;
3332
+ items.push(makeLink('video', 'play-circle', 'Create a video', 'A narrated video about this project, from a set of pre-prompts'));
3333
+ items.push(makeLink('audio', 'microphone', 'Create audio', 'A spoken recording about this project, from a set of pre-prompts'));
3334
+ items.push('<li class="uk-nav-divider"></li>');
3335
+ }
3336
+ if (canBackup) items.push(`<li><a href="${wsUrl(key, '/backups')}"><span uk-icon="icon: album; ratio: .7"></span> Backups for this workspace</a></li>`);
3337
+ if (canRemove) {
3338
+ items.push('<li class="uk-nav-divider"></li>');
3339
+ items.push(`<li><a href class="uk-text-danger" hx-get="/fragments/projects/remove-form/${encodeURIComponent(key)}/${encodeURIComponent(p)}" hx-target="#editor-modal-body" hx-swap="innerHTML"
3340
+ hx-on::after:request="UIkit.modal('#editor-modal').show()"
3341
+ title="Asks about a backup, then confirms, then removes"><span uk-icon="icon: trash; ratio: .7"></span> Remove the project…</a></li>`);
3342
+ }
3343
+ return items.join('');
3344
+ }
3345
+
3288
3346
  function rowGates(dir, meta) {
3289
3347
  const allowed = meta.typeActions;
3290
3348
  return {
@@ -3462,7 +3520,8 @@ async function screensMenuBody(ws, project) {
3462
3520
 
3463
3521
  // The row menus the core draws itself whose bodies load when they open — the slots
3464
3522
  // /fragments/row-menu/<slot>/<ws>/<project> answers with no `body` parameter (coreRowMenuBody).
3465
- const CORE_ROW_MENUS = new Set(['ddev', 'composer', 'tests', 'screens']);
3523
+ // NODE and More joined in #111: a row carries their buttons and captions, not their dropdowns.
3524
+ const CORE_ROW_MENUS = new Set(['ddev', 'composer', 'tests', 'screens', 'node', 'more']);
3466
3525
 
3467
3526
  /**
3468
3527
  * The body of one of the core's row menus for one project, as `{ status, html }`: the dropdown's inner
@@ -3498,19 +3557,27 @@ async function coreRowMenuBody(slot, key, project) {
3498
3557
  const { nav, oob } = await screensMenuBody(key, project);
3499
3558
  return { status: 200, html: `<ul class="uk-nav uk-dropdown-nav">${nav}</ul>${oob}` };
3500
3559
  }
3560
+ if (slot === 'node') {
3561
+ const body = nodeMenuBody(key, project, projectPath, isDdev);
3562
+ return body ? { status: 200, html: body } : none('No package.json here.');
3563
+ }
3564
+ if (slot === 'more') {
3565
+ const items = moreMenuItemsHtml({ key, p: project, gates });
3566
+ return items ? { status: 200, html: `<ul class="uk-nav uk-dropdown-nav">${items}</ul>` } : none('Nothing more to offer here.');
3567
+ }
3501
3568
  return none('No such menu.');
3502
3569
  }
3503
3570
 
3504
3571
  /**
3505
3572
  * The Buttons settings page lists what each menu has been seen to hold (row-layout's seen record). A
3506
3573
  * menu whose body loads when it opens (coreRowMenuBody) is recorded when one opens, so on a dashboard
3507
- * where nobody has opened COMPOSER or Tests yet the page would list nothing for them: before it is drawn,
3574
+ * where nobody has opened COMPOSER, Tests or NODE yet the page would list nothing for them: before it is drawn,
3508
3575
  * each such menu with nothing on record is built once, for the first project that carries it, and
3509
3576
  * arranged — which is what records it. DDEV is counted on every row, so it is on record already, and
3510
3577
  * SCREENS lists whatever sessions happen to run, which is nothing to learn from.
3511
3578
  */
3512
3579
  async function learnCoreMenus() {
3513
- for (const slot of ['composer', 'tests']) {
3580
+ for (const slot of ['composer', 'tests', 'node']) {
3514
3581
  if (Object.keys(rowLayout.seenMenu(slot).groups).length) continue;
3515
3582
  let learned = false;
3516
3583
  for (const [key, meta] of Object.entries(loadWorkspaces())) {
@@ -3727,47 +3794,17 @@ async function projectRowsHtml(key, dir, state) {
3727
3794
  </div><span class="act-status uk-text-meta">${!rag.up ? 'offline' : built ? 'indexed' : 'none'}</span></span>`;
3728
3795
  })() : '';
3729
3796
  const moreMenu = (() => {
3730
- // Both verbs also live in the DDEV menu, where a DDEV project's
3731
- // teardown belongs. They stay here too: More is where someone looks
3732
- // for them, and a folder DDEV does not manage has no other home.
3733
- const items = [];
3734
- // What this product shipped, read from the CHANGELOG it already keeps. It belongs to the
3735
- // row rather than to the rail: a changelog is a fact about one product, and the question
3736
- // people ask is "what went into 1.0.3 of THIS", standing in front of it in its folder.
3737
- // The latest version is in the label, so the menu answers the common case unopened.
3738
- const cl = changelog.changelogFor(key, p);
3739
- if (cl) {
3740
- const latest = (changelog.changelogIndex().find((r) => r.workspace === key && r.item === p) || {}).latest || '';
3741
- items.push(`<li><a href hx-get="/fragments/changelog/${encodeURIComponent(key)}/${encodeURIComponent(p)}"
3742
- hx-target="#editor-modal-body" hx-swap="innerHTML" uk-toggle="target: #editor-modal"
3743
- title="Releases, newest first, from ${esc(cl.file)}"><span uk-icon="icon: history; ratio: .7"></span> Changelog${latest ? ` <span class="uk-text-meta">${esc(latest)}</span>` : ''}</a></li>`);
3744
- }
3745
- // Recordings about this project: a video, or audio, started from a set of pre-prompts.
3746
- if (projectMake.WORKSPACES.includes(key)) {
3747
- const makeLink = (kind, icon, label, hint) => `<li data-entry="${esc(label.toLowerCase())}"><a href hx-get="/fragments/projects/make-form/${encodeURIComponent(key)}/${encodeURIComponent(p)}?kind=${kind}"
3748
- hx-target="#editor-modal-body" hx-swap="innerHTML" hx-on::after:request="UIkit.modal('#editor-modal').show()"
3749
- title="${esc(hint)}"><span uk-icon="icon: ${icon}; ratio: .7"></span> ${esc(label)}…</a></li>`;
3750
- items.push(makeLink('video', 'play-circle', 'Create a video', 'A narrated video about this project, from a set of pre-prompts'));
3751
- items.push(makeLink('audio', 'microphone', 'Create audio', 'A spoken recording about this project, from a set of pre-prompts'));
3752
- items.push('<li class="uk-nav-divider"></li>');
3753
- }
3754
- if (canBackup) items.push(`<li><a href="${wsUrl(key, '/backups')}"><span uk-icon="icon: album; ratio: .7"></span> Backups for this workspace</a></li>`);
3755
- if (canRemove) {
3756
- items.push('<li class="uk-nav-divider"></li>');
3757
- items.push(`<li><a href class="uk-text-danger" hx-get="/fragments/projects/remove-form/${encodeURIComponent(key)}/${encodeURIComponent(p)}" hx-target="#editor-modal-body" hx-swap="innerHTML"
3758
- hx-on::after:request="UIkit.modal('#editor-modal').show()"
3759
- title="Asks about a backup, then confirms, then removes"><span uk-icon="icon: trash; ratio: .7"></span> Remove the project…</a></li>`);
3760
- }
3761
3797
  // Arranged before it is counted, like DDEV — and whether there is a More at all is decided after,
3762
- // since hiding can empty it and a custom action can fill it.
3763
- const arranged = rowLayout.arrangeNavInner('more', items.join(''), rowCtx);
3798
+ // since hiding can empty it and a custom action can fill it. The row carries the button and the
3799
+ // count; the items arrive from /fragments/row-menu/more/<ws>/<project> when the menu opens.
3800
+ const arranged = rowLayout.arrangeNavInner('more', moreMenuItemsHtml({ key, p, gates }), rowCtx);
3764
3801
  if (!/<li\b/.test(arranged)) return '';
3765
3802
  // Dividers are not actions — count only what can be clicked.
3766
3803
  const n = (arranged.match(/<a /g) || []).length;
3767
- return `<span class="act-with-status"><div class="uk-inline act-menu act-menu-more">
3768
- <button class="uk-button uk-button-default uk-button-small" type="button" title="More actions"><span uk-icon="icon: more; ratio: .7"></span> More <span uk-icon="icon: chevron-down; ratio: .6"></span></button>
3769
- <div uk-dropdown="mode: click; pos: bottom-right"><ul class="uk-nav uk-dropdown-nav">${arranged}</ul></div>
3770
- </div><span class="act-status uk-text-meta">${n} action${n === 1 ? '' : 's'}</span></span>`;
3804
+ return menuShellHtml('more', {
3805
+ key, project: p, caption: `${n} action${n === 1 ? '' : 's'}`,
3806
+ html: '<button class="uk-button uk-button-default uk-button-small" type="button" title="More actions"><span uk-icon="icon: more; ratio: .7"></span> More <span uk-icon="icon: chevron-down; ratio: .6"></span></button>',
3807
+ });
3771
3808
  })();
3772
3809
  // SCREENS is drawn beside the running state in the card's head, so the tool strip leaves it out.
3773
3810
  const screensOnTools = '';
@@ -5783,7 +5820,7 @@ const server = http.createServer(async (req, res) => {
5783
5820
  // The body of a plugin's row menu that loads when it opens (plugin-loader's rowMenuHtml): the
5784
5821
  // plugin's own fragment, arranged like an inline menu's items — the Buttons settings hide, order
5785
5822
  // and add to it the same way, and the settings page sees its entries once one has been opened.
5786
- // The core's own menus (DDEV, COMPOSER, Tests, SCREENS) take the same route with no `body`: their
5823
+ // The core's own menus (DDEV, COMPOSER, Tests, SCREENS, NODE, More) take the same route with no `body`: their
5787
5824
  // bodies come from coreRowMenuBody, computed for this one project when the menu opens.
5788
5825
  if (pathname.startsWith('/fragments/row-menu/')) {
5789
5826
  const m = pathname.match(/^\/fragments\/row-menu\/([a-z0-9-]+)\/([a-z0-9_-]+)\/([a-zA-Z0-9_-]+)$/);
@@ -168,10 +168,16 @@ test('a drawing that lost shapes to the sanitiser is drawn again; one that never
168
168
  }
169
169
  });
170
170
 
171
- test('Gemini and Anthropic need a real key; Claude Code draws in a session, retried by its finish step', async () => {
171
+ test('Gemini and Anthropic need a real key; Claude Code draws in a session, retried by its finish step', async (t) => {
172
172
  const noKey = await svg.generateSvg({ prompt: 'x', provider: 'anthropic', confirm: 'yes' });
173
173
  assert.equal(noKey.status, 503);
174
174
  assert.match(noKey.error, /ai_providers\.anthropic\.api_key/);
175
+ // The rest drives Claude Code, which needs the claude CLI on this machine (#113).
176
+ const claude = require('../../images-providers').get('claude_code_cli').available();
177
+ if (!claude.ok) {
178
+ t.skip(`needs Claude Code: ${claude.why}`);
179
+ return;
180
+ }
175
181
  const calls = [];
176
182
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'svgcc-'));
177
183
  try {
@@ -153,7 +153,11 @@ test('the row shows NODE right after COMPOSER, only where there is a package.jso
153
153
  assert.strictEqual(DEFAULT_LINES[1][DEFAULT_LINES[1].indexOf('composer') + 1], 'node', 'NODE must come right after COMPOSER by default');
154
154
  assert.strictEqual((src.match(/\bnode: nodeMenu\b/g) || []).length, 1, 'handed to the layout once per row');
155
155
  const fn = src.slice(src.indexOf('function nodeMenuHtml'), src.indexOf('function openWithMenuHtml'));
156
- assert.match(fn, /if \(!hasPackageJson\(projectPath\)\) return '';/, 'no package.json, no button');
156
+ assert.match(fn, /if \(!hasPackageJson\(projectPath\)\) return null;/, 'no package.json, no button');
157
+ // The row carries the button and the caption; the tabs arrive when the menu opens (#111).
158
+ assert.match(fn, /return menuShellHtml\('node', \{/, 'the row draws the lazy shell');
159
+ assert.match(src, /const CORE_ROW_MENUS = new Set\(\[[^\]]*'node'[^\]]*\]\);/, 'the core serves the NODE body itself');
160
+ assert.match(src, /if \(slot === 'node'\) \{\s*const body = nodeMenuBody\(key, project, projectPath, isDdev\);/, 'coreRowMenuBody answers NODE');
157
161
  assert.match(fn, /uk-tab="active: \$\{Math\.max\(0, MANAGERS\.indexOf\(info\.preferred\)\)\}"/, 'opens on the manager the project uses');
158
162
  assert.match(fn, /info\.locks\[m\] \? 'lock ✓' : 'no lock'/, 'each tab says whether its lock file is there');
159
163
  assert.match(fn, /hx-include="closest \.node-package"/);
@@ -47,7 +47,7 @@ before(async () => {
47
47
  fs.writeFileSync(path.join(site, 'composer.lock'), '{}');
48
48
  fs.writeFileSync(path.join(site, 'package.json'), JSON.stringify({ name: 'site-one', scripts: { 'lint:js': 'eslint .' } }));
49
49
  fs.mkdirSync(path.join(root, 'dev/plain-folder'), { recursive: true });
50
- for (const f of ['cmd-tools-testing.sh', 'cmd-tools-checks.sh', 'cmd-tools-add-users.sh']) fs.writeFileSync(path.join(root, 'dev', f), '#!/usr/bin/env bash\n');
50
+ for (const f of ['cmd-tools-testing.sh', 'cmd-tools-checks.sh', 'cmd-tools-add-users.sh', 'cmd-tools-remove.sh']) fs.writeFileSync(path.join(root, 'dev', f), '#!/usr/bin/env bash\n');
51
51
  // A package: no DDEV, a package.json that declares checks.
52
52
  const pkg = path.join(root, 'products/pkg-one');
53
53
  fs.mkdirSync(pkg, { recursive: true });
@@ -90,7 +90,7 @@ test('a project row carries the button, the caption and an empty shell for each
90
90
  const { status, html } = await text('/dev');
91
91
  assert.strictEqual(status, 200);
92
92
  const row = rowOf(html, 'site-one');
93
- for (const slot of ['ddev', 'composer', 'tests', 'screens']) {
93
+ for (const slot of ['ddev', 'composer', 'tests', 'screens', 'node', 'more']) {
94
94
  const menu = menuOf(row, slot);
95
95
  assert.ok(menu, `${slot}: the row has the menu`);
96
96
  assert.match(menu, new RegExp(`hx-get="/fragments/row-menu/${slot}/dev/site-one"`), `${slot}: the shell asks the core route for its body`);
@@ -108,8 +108,12 @@ test('a project row carries the button, the caption and an empty shell for each
108
108
  assert.match(menuOf(row, 'tests'), /uk-button-secondary/, 'the Tests button as it was');
109
109
  assert.match(menuOf(row, 'screens'), /hx-trigger="menu-opened, load, every 30s, screens-changed from:body"/, 'SCREENS still polls: the hand and the agent badge ride on its answer');
110
110
  assert.match(menuOf(row, 'screens'), /<span class="act-status uk-text-meta">none<\/span>/);
111
+ // NODE says which lock files are there; More counts what it holds (#111).
112
+ assert.match(menuOf(row, 'node'), /<span class="act-status uk-text-meta">no lock<\/span>/);
113
+ assert.match(menuOf(row, 'node'), /class="act-menu-lazy node-dropdown"/, 'the NODE width class rides on the shell');
114
+ assert.match(menuOf(row, 'more'), /<span class="act-status uk-text-meta">\d+ actions?<\/span>/, 'the More caption counts the actions the body will hold');
111
115
  // Nothing of the bodies is on the page.
112
- for (const inner of ['/actions/ddev-restart', '/actions/ddev-describe', '/actions/composer-install', '/actions/testing-configure', '/actions/checks-plan', 'composer-package', 'checks-sub', 'ddev-nav-columns']) {
116
+ for (const inner of ['/actions/ddev-restart', '/actions/ddev-describe', '/actions/composer-install', '/actions/testing-configure', '/actions/checks-plan', 'composer-package', 'checks-sub', 'ddev-nav-columns', 'node-tabs', 'node-col-commands', 'Scripts in package.json', '/fragments/projects/remove-form/dev/site-one', 'Remove the project']) {
113
117
  assert.ok(!row.includes(inner), `${inner} is not on the page before a menu opens`);
114
118
  }
115
119
  // A folder with no DDEV config has the Configure DDEV button and no DDEV menu; no composer.json, no COMPOSER.
@@ -174,7 +178,8 @@ test('the Tests body for a package is the checks alone, and a plain folder in a
174
178
  assert.match(pkg.html, /<span class="checks-alias">css<\/span>/);
175
179
  assert.ok(!pkg.html.includes('/actions/testing-configure') && !pkg.html.includes('/actions/add-users'), 'a packages workspace has no suite and no test users');
176
180
  const page = (await text('/products')).html;
177
- assert.match(menuOf(rowOf(page, 'pkg-one'), 'tests'), /<span class="act-status uk-text-meta">(2 declared|deps missing)<\/span>/, 'the caption says what the package declares');
181
+ // Nothing on a package row posts with its name any more (NODE's body is served, #111): found by its Tests shell.
182
+ assert.match(menuOf(rowOf(page, 'pkg-one', '/fragments/row-menu/tests/products/pkg-one'), 'tests'), /<span class="act-status uk-text-meta">(2 declared|deps missing)<\/span>/, 'the caption says what the package declares');
178
183
  const plain = await text('/fragments/row-menu/tests/dev/plain-folder');
179
184
  assert.strictEqual(plain.status, 200);
180
185
  assert.match(plain.html, /hx-post="\/actions\/testing-configure"/);
@@ -197,13 +202,40 @@ test('the SCREENS body lists the sessions (none here), the ways to start one, an
197
202
  assert.match(legacy.html, /^\s*<li class="uk-nav-header">No screens in this project<\/li>/);
198
203
  });
199
204
 
205
+ test('the NODE body for a site is the header, the yarn and npm tabs and the scripts of package.json (#111)', async () => {
206
+ const { status, html } = await text('/fragments/row-menu/node/dev/site-one');
207
+ assert.strictEqual(status, 200);
208
+ assert.match(html, /^<ul class="uk-nav uk-dropdown-nav">/);
209
+ assert.match(html, /Node · site-one/);
210
+ assert.match(html, /Runs as ddev npm \/ ddev yarn in this project\./);
211
+ assert.match(html, /<ul class="uk-tab node-tabs" data-keep-open uk-tab="active: \d+">/, 'the tabs keep the menu open');
212
+ assert.match(html, /<ul class="uk-switcher">/);
213
+ assert.match(html, /yarn\.lock is not here/);
214
+ assert.match(html, /package-lock\.json is not here/);
215
+ assert.match(html, /hx-include="closest \.node-package"/);
216
+ assert.match(html, /Scripts in package\.json/);
217
+ assert.match(html, /"script":"lint:js"/, 'the script from package.json is offered');
218
+ assert.match(html, /hx-vals='\{"workspace":"dev","projectName":"site-one","manager":"npm"\}'/, 'the items post for this project');
219
+ });
220
+
221
+ test('the More body for a site holds the remove, arranged, and the row counted it (#111)', async () => {
222
+ const { status, html } = await text('/fragments/row-menu/more/dev/site-one');
223
+ assert.strictEqual(status, 200);
224
+ assert.match(html, /^<ul class="uk-nav uk-dropdown-nav">/);
225
+ assert.match(html, /hx-get="\/fragments\/projects\/remove-form\/dev\/site-one"/);
226
+ assert.match(html, /Remove the project…/);
227
+ const page = (await text('/dev')).html;
228
+ const count = Number(/(\d+) actions?/.exec(menuOf(rowOf(page, 'site-one'), 'more'))[1]);
229
+ assert.strictEqual((html.match(/<a /g) || []).length, count, 'the row counts the actions the body holds');
230
+ });
231
+
200
232
  test('a project that is not there, a menu the row would not draw, and a slot that is not a menu are 404', async () => {
201
233
  for (const [p, why] of [
202
234
  ['/fragments/row-menu/ddev/dev/no-such-project', 'no such project'],
203
235
  ['/fragments/row-menu/ddev/dev/plain-folder', 'not a DDEV project'],
204
236
  ['/fragments/row-menu/composer/dev/plain-folder', 'no composer.json'],
205
237
  ['/fragments/row-menu/ddev/products/pkg-one', 'a packages workspace has no DDEV'],
206
- ['/fragments/row-menu/more/dev/site-one', 'More is drawn inline, not served'],
238
+ ['/fragments/row-menu/node/dev/plain-folder', 'no package.json'],
207
239
  ['/fragments/row-menu/ddev/nope/site-one', 'no such workspace'],
208
240
  ['/fragments/row-menu/ddev/dev/site-one?body=/fragments/p/x/menu', 'a core slot takes no plugin body'],
209
241
  ['/fragments/row-menu/x-plugin/dev/site-one', 'a plugin slot nobody registered'],
@@ -226,4 +258,6 @@ test('the Buttons settings page learns the lazy menus\' entries without anyone o
226
258
  assert.ok(seen.composer && Object.keys(seen.composer.groups).length > 0, 'COMPOSER has groups on record');
227
259
  assert.ok(seen.tests && Object.keys(seen.tests.groups).length > 0, 'Tests has groups on record');
228
260
  assert.ok(seen.ddev && Object.keys(seen.ddev.groups).length > 0, 'DDEV is on record from the rows themselves');
261
+ assert.ok(seen.node && Object.keys(seen.node.groups).length > 0, 'NODE has groups on record (learned, #111)');
262
+ assert.ok(seen.more && Object.keys(seen.more.groups).length > 0, 'More is on record from the rows themselves');
229
263
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webship.co/workspace",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
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": {