datagrok-tools 6.5.5 → 6.5.7

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.
@@ -8,6 +8,29 @@
8
8
  # On Windows, use `grok claude` instead — it handles path conversion
9
9
  # and Docker socket mapping automatically.
10
10
 
11
+ # Shared by every jkg_* gateway below. They differ only in image and network alias.
12
+ x-jkg: &jkg
13
+ platform: linux/amd64
14
+ environment:
15
+ GROK_PARAMETERS: |
16
+ {
17
+ "queueSettings": {
18
+ "useQueue": true,
19
+ "amqpHost": "rabbitmq",
20
+ "amqpUser": "guest",
21
+ "amqpPassword": "guest",
22
+ "amqpPort": 5672,
23
+ "pipeHost": "grok_pipe",
24
+ "pipePort": 3000,
25
+ "pipeKey": "test-key",
26
+ "maxConcurrentCalls": 16
27
+ }
28
+ }
29
+ restart: unless-stopped
30
+ depends_on:
31
+ rabbitmq:
32
+ condition: service_started
33
+
11
34
  services:
12
35
  postgres:
13
36
  image: pgvector/pgvector:pg17
@@ -118,33 +141,54 @@ services:
118
141
  restart: unless-stopped
119
142
  profiles: ["full"]
120
143
 
121
- jupyter_kernel_gateway:
122
- image: datagrok/jupyter_kernel_gateway:${DG_VERSION:-latest}
123
- platform: linux/amd64
124
- depends_on:
125
- rabbitmq:
126
- condition: service_started
127
- environment:
128
- GROK_PARAMETERS: |
129
- {
130
- "queueSettings": {
131
- "useQueue": true,
132
- "amqpHost": "rabbitmq",
133
- "amqpUser": "guest",
134
- "amqpPassword": "guest",
135
- "amqpPort": 5672,
136
- "pipeHost": "grok_pipe",
137
- "pipePort": 3000,
138
- "pipeKey": "test-key",
139
- "maxConcurrentCalls": 16
140
- }
141
- }
144
+ # Per-language kernel gateways, which replaced the single all-languages
145
+ # datagrok/jupyter_kernel_gateway image. They version with the Scripting package, not with
146
+ # the platform, so they take their own JKG_VERSION. `scripting`/`full` starts Python and R;
147
+ # add `jkg_octave`, `jkg_julia` or `jkg_nodejs` when a package needs those languages.
148
+ jkg_python:
149
+ <<: *jkg
150
+ image: datagrok/jkg_python:${JKG_VERSION:-latest}
142
151
  networks:
143
152
  dg:
144
153
  aliases:
145
- - jupyter_kernel_gateway
146
- restart: unless-stopped
147
- profiles: ["scripting", "full"]
154
+ - jkg_python
155
+ profiles: ["scripting", "full", "jkg_python"]
156
+
157
+ jkg_r:
158
+ <<: *jkg
159
+ image: datagrok/jkg_r:${JKG_VERSION:-latest}
160
+ networks:
161
+ dg:
162
+ aliases:
163
+ - jkg_r
164
+ profiles: ["scripting", "full", "jkg_r"]
165
+
166
+ jkg_octave:
167
+ <<: *jkg
168
+ image: datagrok/jkg_octave:${JKG_VERSION:-latest}
169
+ networks:
170
+ dg:
171
+ aliases:
172
+ - jkg_octave
173
+ profiles: ["jkg_octave"]
174
+
175
+ jkg_julia:
176
+ <<: *jkg
177
+ image: datagrok/jkg_julia:${JKG_VERSION:-latest}
178
+ networks:
179
+ dg:
180
+ aliases:
181
+ - jkg_julia
182
+ profiles: ["jkg_julia"]
183
+
184
+ jkg_nodejs:
185
+ <<: *jkg
186
+ image: datagrok/jkg_nodejs:${JKG_VERSION:-latest}
187
+ networks:
188
+ dg:
189
+ aliases:
190
+ - jkg_nodejs
191
+ profiles: ["jkg_nodejs"]
148
192
 
149
193
  world:
150
194
  image: datagrok/demo_db_postgres:12-1.1.0
package/CHANGELOG.md CHANGED
@@ -1,7 +1,19 @@
1
1
  # Datagrok-tools changelog
2
2
 
3
- ## 6.5.5 (WIP)
3
+ ## 6.5.7 (2026-08-28)
4
4
 
5
+ * GROK-20789: `grok s packages` — full package lifecycle from the CLI: `install <name>... [--version <v>]` (server pulls released versions from the configured package repository / npm, `latest` by default), `uninstall`, `update <name>... | --all` (preserves `latest` auto-update tracking; pins pinned packages to the concrete registry-latest), `outdated`, `versions`, `set-version`, and `share`. Multi-package install/update runs sequentially with a per-package status table; a nonexistent package or version is a per-package `error` and exit code 1. Also fixed the generic `packages delete`, which was hitting a nonexistent `/public/v1` endpoint.
6
+ * `grok s` — handler errors now print a clean `{"error": ...}` and exit 1 instead of a raw stack trace with exit 255 (handler dispatch was not awaited, so rejections escaped the error boundary).
7
+ * `--version` values are no longer numerically coerced (`--version 1.10` used to arrive as `1.1`; affected `grok claude` too).
8
+ * `grok test` — a Playwright pass that fails before running specs now reports a failing test instead of nothing. It returned an empty CSV, and the caller drops an empty Playwright result whenever the Puppeteer pass produced rows, so a suite whose config would not load (or that collected zero specs) was indistinguishable from a clean run — Build-Deploy #1277 printed "Playwright: no JSON report produced" and then "Tests passed", exit 0, for six packages. Every early exit now writes one failing row to `test-report-playwright.csv` and the merged report, and a report containing no specs is treated as a failure rather than a pass.
9
+
10
+ ## 6.5.6 (2026-08-20)
11
+
12
+ * `grok report` (`ticket`, `comment`, `label`, `attach`) — support Atlassian **service-account** tokens alongside user API tokens, chosen by the token itself. A user token (`ATATT...`) keeps HTTP Basic with `JIRA_USER` + `JIRA_TOKEN` against the site. A service-account token (`ATSTT...`, minted at admin.atlassian.com) is a scoped token that the site host will not accept — Basic returns 401 and Bearer returns 403 `"Failed to parse Connect Session Auth Token"` — so it is sent as Bearer to the API gateway at `api.atlassian.com/ex/jira/<cloudId>`, with the cloud id read once from the site's public `/_edge/tenant_info` (override with `$JIRA_CLOUD_ID`). `$JIRA_URL` and `--jira-url` still mean the SITE; the gateway address is derived from it, never substituted for it. A service token needs no `JIRA_USER`, so the credential checks no longer demand one.
13
+
14
+ ## 6.5.5 (2026-08-12)
15
+
16
+ * `grok test` — added a Node (browserless) pass: tests annotated `{node: true}` run headless under the js-api Node runtime before the browser launches; the browser pass excludes them and is skipped entirely when nothing browser-only matches. New flags: `--skip-node`, `--node-only`. Packages opt in by exporting `testNode()` from `package-test.ts`; others keep the previous behavior.
5
17
  * GROK-20298: `grok api --ui` — a relation-bearing table's `<Table>Ui` handle carries `<Table>Update` as its fifth generic too (`<Table>Ui.client`, `.table()` and the `<Schema>UiDb` property), so `<table>Ui.client.update(id, {labels: [...]})` compiles instead of failing the excess-property check.
6
18
  * GROK-20298: `grok api` — typed many-to-many relations: a table's `relations` block is validated with the server's rules (via/target declared in the same manifest, junction distinct from both sides, self-referential relations explicit, FK sides unique-or-explicit, junction `businessKey` covering both) and emitted as an expand-map entry `'<name>': {<name>?: {id: string; name: string}[]}`, a `<name>?: string[]` link set on `<Table>Insert`, and a new `<Table>Update` type (`Partial<<Table>Row>` plus the link sets) used by the transaction op union and passed as the client's fifth generic, so `update()` takes the link sets directly. Manifests without relations generate byte-identically.
7
19
  * GROK-20298: `grok api --ui` — also emits the typed SCHEMA handle: `export function <schema>UiDb(): Promise<<Schema>UiDb>` (a typed `domains.db(...)`), and the `<Schema>UiDb` interface types each table property (`const db = await gritUiDb(); db.issues.form({values: {...}})` is compile-checked).
@@ -28,10 +40,6 @@
28
40
 
29
41
  * `grok test` — the Node-pass report now merges into the browser report by column name. The line-wise merge assumed identical column order, so node rows landed misaligned (string values in the integer `ms` column) and the CI test-report upload failed with `invalid input syntax for type integer`.
30
42
 
31
- ## 6.5.5 (WIP)
32
-
33
- * `grok test` — added a Node (browserless) pass: tests annotated `{node: true}` run headless under the js-api Node runtime before the browser launches; the browser pass excludes them and is skipped entirely when nothing browser-only matches. New flags: `--skip-node`, `--node-only`. Packages opt in by exporting `testNode()` from `package-test.ts`; others keep the previous behavior.
34
-
35
43
  ## 6.4.8 (2026-07-22)
36
44
 
37
45
  * GROK-20452: `grok publish` — always `docker build` the tools-generated worker dirs (`dockerfiles/queue`, `dockerfiles/celery`, `dockerfiles/<app>-celery`) instead of reusing a cached local tag. The "found local image" shortcut froze the worker at whatever stock base the daemon had when the tag was first built (CI ran day-old worker code with a fresh base available); the layer cache keeps an unchanged rebuild near-instant.
package/CLAUDE.md CHANGED
@@ -251,6 +251,16 @@ grok s connections test --json conn.json # test a connectio
251
251
  grok s functions run 'Chem:smilesToMw("ccc")'
252
252
  grok s functions run 'Pkg:fn({a:5,b:22})'
253
253
 
254
+ # Manage packages (server pulls released versions from the package repository / npm)
255
+ grok s packages install Chem Bio PowerGrid # install latest of each
256
+ grok s packages install Chem --version 1.14.0 # pin a version
257
+ grok s packages outdated # installed vs registry-latest
258
+ grok s packages update --all # upgrade everything outdated
259
+ grok s packages versions Chem # published versions + current/latest flags
260
+ grok s packages set-version Chem 1.13.0 # activate a specific version
261
+ grok s packages uninstall Chem # repo entry stays installable
262
+ grok s packages share Chem Chemists --access View
263
+
254
264
  # Browse file storage
255
265
  grok s files list "System:AppData" -r # list files recursively
256
266
  grok s files list "System:AppData/MyPlugin"
package/GROK_S.md CHANGED
@@ -29,6 +29,8 @@ browser or a logged-in session.
29
29
  | List / browse files in a file share | `grok s files list "System:AppData" -r` |
30
30
  | Upload / download a table (CSV or d42) | `grok s tables upload <name> file.csv\|file.d42` / `tables download <name> -O out.csv` |
31
31
  | Check whether a package is deployed | `grok s packages list --filter "MyPlugin"` |
32
+ | Install / update / uninstall server plugins | `grok s packages install Chem Bio` / `packages update --all` / `packages uninstall Chem` |
33
+ | See installed vs latest plugin versions | `grok s packages outdated` / `packages versions Chem` |
32
34
  | Hit any undocumented endpoint | `grok s raw GET /api/users/current` |
33
35
  | Check server + per-module health | `grok s healthcheck [--module <name>]` |
34
36
  | Bulk operations in one round-trip | `grok s batch <entity> <verb> --json items.json` |
@@ -226,6 +228,56 @@ table|json|csv|quiet`), which still controls how the upload result is printed.
226
228
  Download is CSV-only — the server reads the stored d42 blob and converts. If you
227
229
  need the raw d42 bytes, hit `/tables/data/<id>` directly via `grok s raw`.
228
230
 
231
+ ## Packages
232
+
233
+ Manage server plugins the same way the Package Manager UI does — the server pulls
234
+ published packages from its configured package repository (npm). This is for
235
+ installing **released** packages on a running server; publishing your own local
236
+ package sources is still `grok publish`.
237
+
238
+ ```bash
239
+ grok s packages install Chem Bio PowerGrid # install latest of each, one line
240
+ grok s packages install Chem --version 1.14.0 # pin a specific version (single package only)
241
+ grok s packages outdated # installed vs registry-latest
242
+ grok s packages update Chem Bio # upgrade named packages to latest
243
+ grok s packages update --all # upgrade everything outdated
244
+ grok s packages versions Chem # published versions w/ current/latest/debug flags
245
+ grok s packages set-version Chem 1.13.0 # activate a specific version (pulls it if needed)
246
+ grok s packages uninstall Chem # remove; repository entry stays installable
247
+ grok s packages share Chem Chemists --access View
248
+ ```
249
+
250
+ Semantics worth knowing:
251
+
252
+ - **`install` without `--version` sets the package to `latest`**, which is an
253
+ auto-update intent: the server re-resolves it against the registry every ~15
254
+ minutes, so the package keeps itself current. A pinned `--version` (and `update`,
255
+ which pins the concrete latest version) stays put until you change it.
256
+ - **Install is synchronous** — the call returns once the version is downloaded,
257
+ published, and made current. A heavy first-time install (e.g. Chem) can take
258
+ minutes; if the HTTP call times out, just re-run it — install is idempotent, and
259
+ a version that finished installing server-side is activated instantly on retry.
260
+ - Multi-package `install`/`update` runs sequentially and prints a per-package
261
+ status table (`installed` / `noop` / `error`); a partial failure sets exit code 1.
262
+ - **`update` preserves auto-update tracking**: a package whose desired version is
263
+ `latest` is updated by re-resolving `latest` (it keeps auto-updating); a pinned
264
+ package gets pinned to the concrete registry-latest version. Note that a
265
+ locally-published dev build counts as outdated once the registry moves past its
266
+ base version, so `update --all` will replace it with the registry release.
267
+ - Status semantics: `noop` means "already at the requested latest"; installing an
268
+ explicit `--version` reports `installed` (with the published-version id) even
269
+ when that version was already current — the server re-activates it.
270
+ - **`uninstall` of a repository-backed package keeps the package entry** (it shows
271
+ up as installable again); only locally-published packages are deleted outright.
272
+ It also clears the desired version, so the package drops out of `outdated` until
273
+ reinstalled. Neither path cleans up package credentials or package DB schemas.
274
+ - `versions`, `outdated`, and name resolution read the repository catalog, so they
275
+ need a configured package repository — check with `grok s raw GET /api/packages/repos`.
276
+ - `share` targets the package's project under the hood (same as sharing from the UI),
277
+ so the whole package — functions, queries, connections — is shared at once. For the
278
+ same reason `grok s shares list <package-uuid>` won't show the grant — it lives on
279
+ the package's project, not the package entity itself.
280
+
229
281
  ## Server health
230
282
 
231
283
  ```bash
@@ -424,6 +424,14 @@ Special commands:
424
424
  grok s connections test --json conn.json Test connectivity of a connection defined in JSON
425
425
  grok s tables upload <name> <file.csv> Upload a CSV as a Datagrok table
426
426
  grok s tables download <name-or-id> [-O <file>] Download a table as CSV (stdout by default)
427
+ grok s packages install <name>... [--version <v>] Install latest (or pinned) versions from the registry
428
+ grok s packages uninstall <name> Uninstall a package
429
+ grok s packages update <name>... | --all Upgrade to the latest registry version
430
+ grok s packages outdated Show installed vs latest registry versions
431
+ grok s packages versions <name> List published versions
432
+ grok s packages set-version <name> <version> Activate a specific published version
433
+ grok s packages share <name> <group> [--access View|Edit]
434
+ Share a package with groups
427
435
  grok s raw <METHOD> <path> Hit any API endpoint
428
436
  grok s describe <entity-type> Show entity JSON schema
429
437
 
@@ -449,6 +457,8 @@ Examples:
449
457
  grok s shares add "JohnDoe:MyConnection" Chemists,Admins --access Edit
450
458
  grok s shares list <entity-uuid>
451
459
  grok s functions run 'Chem:smilesToMw("ccc")'
460
+ grok s packages install Chem Bio PowerGrid
461
+ grok s packages update --all
452
462
  grok s files list "System:AppData" -r
453
463
  grok s raw GET /api/users/current
454
464
  grok s describe connections
@@ -367,10 +367,10 @@ async function handleTicket(args) {
367
367
  const issueType = args['type'] || 'Bug';
368
368
  const auth = jiraAuthHeader();
369
369
  if (auth == null) {
370
- color.error('JIRA_USER and JIRA_TOKEN env vars are required for `grok report ticket`.');
370
+ color.error('Set JIRA_TOKEN (plus JIRA_USER for a user API token) for `grok report ticket`.');
371
371
  return false;
372
372
  }
373
- const jiraBase = resolveJiraBase(args);
373
+ const jiraBase = await resolveJiraBase(args);
374
374
  try {
375
375
  const {
376
376
  url,
@@ -444,10 +444,23 @@ async function handleTicket(args) {
444
444
 
445
445
  // ─── JIRA REST helpers (used by `grok report comment` / `grok report label`) ─
446
446
  //
447
- // These talk DIRECTLY to Atlassian Cloud REST v2 (not Datagrok). Auth is HTTP
448
- // Basic with `JIRA_USER` (Atlassian email) + `JIRA_TOKEN` (API token from
449
- // id.atlassian.com/manage-profile/security/api-tokens). Base URL defaults to
450
- // the Datagrok org instance; override via --jira-url or $JIRA_URL.
447
+ // These talk DIRECTLY to Atlassian Cloud REST v2 (not Datagrok). Two auth schemes, picked from
448
+ // the token itself:
449
+ //
450
+ // * user API token (`ATATT...`, from id.atlassian.com/manage-profile/security/api-tokens)
451
+ // HTTP Basic with `JIRA_USER` + `JIRA_TOKEN`, against the site. The original path.
452
+ // * service-account token (`ATSTT...`, from admin.atlassian.com) — a SCOPED token. The site
453
+ // host will not take it: Basic gives 401, and Bearer gives 403 "Failed to parse Connect
454
+ // Session Auth Token" because Jira tries to read it as a Connect session. Only the API
455
+ // gateway accepts it, addressed by cloud id, with Bearer. No JIRA_USER is involved.
456
+ //
457
+ // Both are supported on purpose. Automation moved to a service account when its bot mailbox
458
+ // became a Google group (a group holds no Atlassian identity and cannot mint tokens), while
459
+ // release-notes CI and every developer's local setup still use user tokens.
460
+ //
461
+ // Base URL defaults to the Datagrok org instance; override via --jira-url or $JIRA_URL. That
462
+ // value stays the SITE — the gateway address is derived from it, never substituted for it, so
463
+ // human-facing links elsewhere keep working.
451
464
  //
452
465
  // Why v2 and not v3: v3 requires comment bodies in ADF (Atlassian Document
453
466
  // Format) JSON, which is much heavier to construct. v2 accepts a plain string
@@ -455,15 +468,49 @@ async function handleTicket(args) {
455
468
  // becomes a top-level ordered-list item, `&nbsp;` shows up literally, etc.
456
469
  // `markdownToJiraWiki` below bridges the gap for Markdown-emitting callers.
457
470
 
458
- function resolveJiraBase(args) {
471
+ const JIRA_SERVICE_TOKEN_RE = /^ATSTT/;
472
+ function isJiraServiceToken() {
473
+ return JIRA_SERVICE_TOKEN_RE.test(process.env.JIRA_TOKEN || '');
474
+ }
475
+
476
+ // The site, as configured. Human-facing links are built from this.
477
+ function resolveJiraSite(args) {
459
478
  const cli = args['jira-url'] || '';
460
479
  const env = process.env.JIRA_URL || '';
461
480
  return (cli || env || 'https://reddata.atlassian.net').replace(/\/+$/, '');
462
481
  }
482
+ let cloudIdCache = null;
483
+
484
+ // The gateway addresses a site by cloud id rather than hostname. Public endpoint, no credentials
485
+ // needed, cached for the process.
486
+ async function resolveJiraCloudId(site) {
487
+ if (process.env.JIRA_CLOUD_ID) return process.env.JIRA_CLOUD_ID.trim();
488
+ if (cloudIdCache !== null) return cloudIdCache;
489
+ try {
490
+ const r = await fetch(`${site}/_edge/tenant_info`);
491
+ const j = r.ok ? await r.json() : {};
492
+ cloudIdCache = (j.cloudId || '').trim();
493
+ } catch {
494
+ cloudIdCache = '';
495
+ }
496
+ return cloudIdCache;
497
+ }
498
+
499
+ // Where REST calls go. A service token needs the gateway; anything else keeps the site. If the
500
+ // cloud id cannot be resolved, fall back to the site rather than failing outright — the request
501
+ // then reports a normal auth error instead of a confusing lookup one.
502
+ async function resolveJiraBase(args) {
503
+ const site = resolveJiraSite(args);
504
+ if (!isJiraServiceToken()) return site;
505
+ const cloudId = await resolveJiraCloudId(site);
506
+ return cloudId ? `https://api.atlassian.com/ex/jira/${cloudId}` : site;
507
+ }
463
508
  function jiraAuthHeader() {
464
- const user = process.env.JIRA_USER;
465
509
  const token = process.env.JIRA_TOKEN;
466
- if (!user || !token) return null;
510
+ if (!token) return null;
511
+ if (isJiraServiceToken()) return `Bearer ${token}`;
512
+ const user = process.env.JIRA_USER;
513
+ if (!user) return null;
467
514
  return 'Basic ' + Buffer.from(`${user}:${token}`).toString('base64');
468
515
  }
469
516
 
@@ -544,7 +591,7 @@ async function handleComment(args) {
544
591
  }
545
592
  const auth = jiraAuthHeader();
546
593
  if (auth == null) {
547
- color.error('JIRA_USER and JIRA_TOKEN env vars are required for `grok report comment`.');
594
+ color.error('Set JIRA_TOKEN (plus JIRA_USER for a user API token) for `grok report comment`.');
548
595
  return false;
549
596
  }
550
597
  let body;
@@ -566,7 +613,7 @@ async function handleComment(args) {
566
613
  color.error('Comment body is empty (use --body, --body-file, or pipe to stdin).');
567
614
  return false;
568
615
  }
569
- const base = resolveJiraBase(args);
616
+ const base = await resolveJiraBase(args);
570
617
  const url = `${base}/rest/api/2/issue/${encodeURIComponent(ticket)}/comment`;
571
618
  // Callers (especially the dg-fix-reports M2 handoff) emit Markdown, but JIRA
572
619
  // REST v2 renders the body as wiki markup. Convert before posting so headings,
@@ -614,13 +661,15 @@ async function handleAttach(args) {
614
661
  color.error(`File not found: ${filePath}`);
615
662
  return false;
616
663
  }
617
- const user = process.env.JIRA_USER;
618
- const token = process.env.JIRA_TOKEN;
619
- if (!user || !token) {
620
- color.error('JIRA_USER and JIRA_TOKEN env vars are required for `grok report attach`.');
664
+
665
+ // Through the same helper as every other call: a service token authenticates with Bearer and
666
+ // has no JIRA_USER at all, so checking for one here would refuse a working setup.
667
+ const auth = jiraAuthHeader();
668
+ if (auth == null) {
669
+ color.error('Set JIRA_TOKEN (plus JIRA_USER for a user API token) for `grok report attach`.');
621
670
  return false;
622
671
  }
623
- const base = resolveJiraBase(args);
672
+ const base = await resolveJiraBase(args);
624
673
  const url = `${base}/rest/api/2/issue/${encodeURIComponent(ticket)}/attachments`;
625
674
 
626
675
  // node-fetch v2 has no built-in FormData and the codebase doesn't depend on
@@ -630,7 +679,7 @@ async function handleAttach(args) {
630
679
  const {
631
680
  spawnSync
632
681
  } = require('child_process');
633
- const r = spawnSync('curl', ['-sS', '-X', 'POST', '-u', `${user}:${token}`, '-H', 'X-Atlassian-Token: no-check', '-F', `file=@${filePath}`, '-w', '\n%{http_code}\n', url], {
682
+ const r = spawnSync('curl', ['-sS', '-X', 'POST', '-H', `Authorization: ${auth}`, '-H', 'X-Atlassian-Token: no-check', '-F', `file=@${filePath}`, '-w', '\n%{http_code}\n', url], {
634
683
  encoding: 'utf8',
635
684
  timeout: 120_000,
636
685
  maxBuffer: 4 * 1024 * 1024
@@ -671,10 +720,10 @@ async function handleLabel(args) {
671
720
  }
672
721
  const auth = jiraAuthHeader();
673
722
  if (auth == null) {
674
- color.error('JIRA_USER and JIRA_TOKEN env vars are required for `grok report label`.');
723
+ color.error('Set JIRA_TOKEN (plus JIRA_USER for a user API token) for `grok report label`.');
675
724
  return false;
676
725
  }
677
- const base = resolveJiraBase(args);
726
+ const base = await resolveJiraBase(args);
678
727
  const url = `${base}/rest/api/2/issue/${encodeURIComponent(ticket)}`;
679
728
  const update = {
680
729
  labels: labels.map(l => ({
@@ -41,13 +41,15 @@ async function server(argv) {
41
41
  }
42
42
  const dapi = new _nodeDapi.NodeDapi(client);
43
43
  try {
44
- if (entity === 'batch') return handleBatch(dapi, argv, verb, rest, output);
45
- if (entity === 'raw') return handleRaw(dapi, verb, rest, output);
46
- if (entity === 'describe') return handleDescribe(dapi, verb ?? rest[0], output);
47
- if (entity === 'healthcheck') return handleHealthcheck(dapi, argv, output);
48
- if (entity === 'sync') return handleSync(dapi, verb, rest, argv, output);
49
- if (entity === 'functions' && verb === 'run') return handleFuncRun(dapi, rest, argv, output);
50
- if (entity === 'functions' && verb === 'list') return handleFunctionsList(dapi, argv, limit, offset, filter, output);
44
+ // await each handler so a rejection lands in the catch below instead of
45
+ // escaping to grok.js as an unhandled rejection (stack trace + exit 255)
46
+ if (entity === 'batch') return await handleBatch(dapi, argv, verb, rest, output);
47
+ if (entity === 'raw') return await handleRaw(dapi, verb, rest, output);
48
+ if (entity === 'describe') return await handleDescribe(dapi, verb ?? rest[0], output);
49
+ if (entity === 'healthcheck') return await handleHealthcheck(dapi, argv, output);
50
+ if (entity === 'sync') return await handleSync(dapi, verb, rest, argv, output);
51
+ if (entity === 'functions' && verb === 'run') return await handleFuncRun(dapi, rest, argv, output);
52
+ if (entity === 'functions' && verb === 'list') return await handleFunctionsList(dapi, argv, limit, offset, filter, output);
51
53
  if (entity === 'files' && verb === 'list') {
52
54
  const path = rest[0] ?? '';
53
55
  const result = await dapi.files.list(path, recursive);
@@ -64,21 +66,28 @@ async function server(argv) {
64
66
  if (output !== 'quiet') console.log(`Deleted ${rest[0]}`);
65
67
  return true;
66
68
  }
67
- if (entity === 'files' && verb === 'put') return handleFilesPut(dapi, rest, output);
68
- if (entity === 'shares' && verb === 'add') return handleSharesAdd(dapi, rest, argv, output);
69
- if (entity === 'shares' && verb === 'list') return handleSharesList(dapi, rest, output);
70
- if (entity === 'users' && verb === 'save') return handleUserSave(dapi, argv, output);
71
- if (entity === 'groups' && verb === 'save') return handleGroupSave(dapi, argv, output);
72
- if (entity === 'connections' && verb === 'save') return handleConnSave(dapi, argv, output);
73
- if (entity === 'connections' && verb === 'test') return handleConnTest(dapi, rest, argv, output);
74
- if (entity === 'groups' && verb === 'add-members') return handleGroupAddMembers(dapi, rest, argv, output);
75
- if (entity === 'groups' && verb === 'remove-members') return handleGroupRemoveMembers(dapi, rest, argv, output);
76
- if (entity === 'groups' && verb === 'list-members') return handleGroupListMembers(dapi, rest, argv, output);
77
- if (entity === 'groups' && verb === 'list-memberships') return handleGroupListMemberships(dapi, rest, argv, output);
78
- if (entity === 'users' && verb === 'block') return handleUserBlock(dapi, rest, output);
79
- if (entity === 'users' && verb === 'unblock') return handleUserUnblock(dapi, rest, output);
80
- if (entity === 'tables' && verb === 'download') return handleTablesDownload(dapi, rest, argv, output);
81
- if (entity === 'tables' && verb === 'upload') return handleTablesUpload(dapi, rest, output);
69
+ if (entity === 'files' && verb === 'put') return await handleFilesPut(dapi, rest, output);
70
+ if (entity === 'shares' && verb === 'add') return await handleSharesAdd(dapi, rest, argv, output);
71
+ if (entity === 'shares' && verb === 'list') return await handleSharesList(dapi, rest, output);
72
+ if (entity === 'users' && verb === 'save') return await handleUserSave(dapi, argv, output);
73
+ if (entity === 'groups' && verb === 'save') return await handleGroupSave(dapi, argv, output);
74
+ if (entity === 'connections' && verb === 'save') return await handleConnSave(dapi, argv, output);
75
+ if (entity === 'connections' && verb === 'test') return await handleConnTest(dapi, rest, argv, output);
76
+ if (entity === 'groups' && verb === 'add-members') return await handleGroupAddMembers(dapi, rest, argv, output);
77
+ if (entity === 'groups' && verb === 'remove-members') return await handleGroupRemoveMembers(dapi, rest, argv, output);
78
+ if (entity === 'groups' && verb === 'list-members') return await handleGroupListMembers(dapi, rest, argv, output);
79
+ if (entity === 'groups' && verb === 'list-memberships') return await handleGroupListMemberships(dapi, rest, argv, output);
80
+ if (entity === 'users' && verb === 'block') return await handleUserBlock(dapi, rest, output);
81
+ if (entity === 'users' && verb === 'unblock') return await handleUserUnblock(dapi, rest, output);
82
+ if (entity === 'tables' && verb === 'download') return await handleTablesDownload(dapi, rest, argv, output);
83
+ if (entity === 'tables' && verb === 'upload') return await handleTablesUpload(dapi, rest, output);
84
+ if (entity === 'packages' && verb === 'install') return await handlePackagesInstall(dapi, rest, argv, output);
85
+ if (entity === 'packages' && verb === 'uninstall') return await handlePackagesUninstall(dapi, rest, output);
86
+ if (entity === 'packages' && verb === 'share') return await handlePackagesShare(dapi, rest, argv, output);
87
+ if (entity === 'packages' && verb === 'versions') return await handlePackagesVersions(dapi, rest, output);
88
+ if (entity === 'packages' && verb === 'set-version') return await handlePackagesSetVersion(dapi, rest, output);
89
+ if (entity === 'packages' && verb === 'outdated') return await handlePackagesOutdated(dapi, output);
90
+ if (entity === 'packages' && verb === 'update') return await handlePackagesUpdate(dapi, rest, argv, output);
82
91
  const source = dapi[entity];
83
92
  if (!source || !ENTITIES.includes(entity)) {
84
93
  (0, _serverOutput.printError)(new Error(`Unknown entity type: '${entity}'. Valid: ${ENTITIES.join(', ')}`));
@@ -112,7 +121,8 @@ async function server(argv) {
112
121
  if (output !== 'quiet') console.log(`Deleted ${entity}/${rest[0]}`);
113
122
  return true;
114
123
  }
115
- (0, _serverOutput.printError)(new Error(`Unknown verb: '${verb}'. Valid: ${VERBS.join(', ')}`));
124
+ const extraVerbs = entity === 'packages' ? ', install, uninstall, update, outdated, versions, set-version, share' : '';
125
+ (0, _serverOutput.printError)(new Error(`Unknown verb: '${verb}'. Valid: ${VERBS.join(', ')}${extraVerbs}`));
116
126
  return false;
117
127
  } catch (err) {
118
128
  (0, _serverOutput.printError)(err);
@@ -168,6 +178,208 @@ async function handleTablesDownload(dapi, rest, argv, output) {
168
178
  } else process.stdout.write(csv);
169
179
  return true;
170
180
  }
181
+ async function installPackages(dapi, targets, output) {
182
+ const results = [];
183
+ for (const t of targets) {
184
+ let name = t.name;
185
+ try {
186
+ const pkg = await dapi.packages.resolve(t.name);
187
+ if (pkg) name = pkg.name;
188
+ if (output === 'table') console.log(`Installing ${name} (${t.version})...`);
189
+ const id = await dapi.packages.install(name, t.version);
190
+ // the server returns the published id for any existing explicit version
191
+ // (even an already-current one), so null means the version doesn't exist
192
+ if (!id && t.version !== 'latest') results.push({
193
+ package: name,
194
+ version: t.version,
195
+ status: 'error',
196
+ error: `Version '${t.version}' not found`
197
+ });else results.push({
198
+ package: name,
199
+ version: t.version,
200
+ status: id ? 'installed' : 'noop',
201
+ id: id ?? undefined
202
+ });
203
+ } catch (err) {
204
+ results.push({
205
+ package: name,
206
+ version: t.version,
207
+ status: 'error',
208
+ error: (err?.message ?? String(err)).replace(/\s*\n\s*/g, '; ')
209
+ });
210
+ }
211
+ }
212
+ return results;
213
+ }
214
+ function printPackageOps(results, output) {
215
+ if (output === 'quiet') {
216
+ for (const r of results) if (r.id) console.log(r.id);
217
+ } else (0, _serverOutput.printOutput)(results, output);
218
+ if (results.some(r => r.status === 'error')) process.exitCode = 1;
219
+ }
220
+ async function handlePackagesInstall(dapi, rest, argv, output) {
221
+ if (!rest.length) {
222
+ (0, _serverOutput.printError)(new Error('Usage: grok s packages install <name> [<name> ...] [--version <v>]'));
223
+ return false;
224
+ }
225
+ if (argv.version !== undefined && rest.length > 1) {
226
+ (0, _serverOutput.printError)(new Error('--version applies to a single package'));
227
+ return false;
228
+ }
229
+ const version = argv.version !== undefined ? String(argv.version) : 'latest';
230
+ const results = await installPackages(dapi, rest.map(r => ({
231
+ name: String(r),
232
+ version
233
+ })), output);
234
+ printPackageOps(results, output);
235
+ return true;
236
+ }
237
+ async function handlePackagesUninstall(dapi, rest, output) {
238
+ if (!rest[0]) {
239
+ (0, _serverOutput.printError)(new Error('Usage: grok s packages uninstall <name-or-id>'));
240
+ return false;
241
+ }
242
+ const result = await dapi.packages.uninstall(rest[0]);
243
+ if (output === 'json' || output === 'csv') (0, _serverOutput.printOutput)(result, output);else if (output === 'quiet') console.log(result.id);else console.log(result.repoBacked ? `Uninstalled ${result.name} (package entry kept — reinstall with 'grok s packages install ${result.name}')` : `Deleted local package ${result.name}`);
244
+ return true;
245
+ }
246
+ async function handlePackagesShare(dapi, rest, argv, output) {
247
+ const [name, ...groupArgs] = rest;
248
+ if (!name || !groupArgs.length) {
249
+ (0, _serverOutput.printError)(new Error('Usage: grok s packages share <name-or-id> <group>[,<group>...] [--access View|Edit]'));
250
+ return false;
251
+ }
252
+ const groups = groupArgs.flatMap(g => g.split(',')).map(g => g.trim()).filter(Boolean).join(',');
253
+ const access = typeof argv.access === 'string' ? argv.access : 'View';
254
+ if (access !== 'View' && access !== 'Edit') {
255
+ (0, _serverOutput.printError)(new Error(`Invalid --access '${access}'. Use 'View' or 'Edit'.`));
256
+ return false;
257
+ }
258
+ const pkg = await dapi.packages.resolve(name);
259
+ if (!pkg) {
260
+ (0, _serverOutput.printError)(new Error(`Package '${name}' not found`));
261
+ return false;
262
+ }
263
+ const result = await dapi.shares.share(pkg.id, groups, access);
264
+ (0, _serverOutput.printOutput)(result, output);
265
+ if (result?.status === 'failed') process.exitCode = 1;
266
+ return true;
267
+ }
268
+ async function handlePackagesVersions(dapi, rest, output) {
269
+ if (!rest[0]) {
270
+ (0, _serverOutput.printError)(new Error('Usage: grok s packages versions <name-or-id>'));
271
+ return false;
272
+ }
273
+ const versions = await dapi.packages.versions(rest[0]);
274
+ const flat = versions.map(v => ({
275
+ version: v?.version ?? '',
276
+ current: v?.isCurrent ?? false,
277
+ latest: v?.isLatest ?? false,
278
+ local: v?.isLocal ?? false,
279
+ remote: v?.isRemote ?? false,
280
+ debug: v?.debug ?? false,
281
+ id: v?.id ?? ''
282
+ }));
283
+ (0, _serverOutput.printOutput)(flat, output);
284
+ return true;
285
+ }
286
+ async function handlePackagesSetVersion(dapi, rest, output) {
287
+ if (!rest[0] || rest[1] === undefined) {
288
+ (0, _serverOutput.printError)(new Error('Usage: grok s packages set-version <name> <version>'));
289
+ return false;
290
+ }
291
+ const version = String(rest[1]);
292
+ const pkg = await dapi.packages.resolve(String(rest[0]));
293
+ const name = pkg?.name ?? String(rest[0]);
294
+ const id = await dapi.packages.install(name, version);
295
+ if (!id) {
296
+ (0, _serverOutput.printError)(new Error(`Version '${version}' not found for package '${name}'`));
297
+ return false;
298
+ }
299
+ if (output === 'quiet') console.log(id);else if (output === 'json' || output === 'csv') (0, _serverOutput.printOutput)({
300
+ package: name,
301
+ version,
302
+ id
303
+ }, output);else console.log(`${name} -> ${version} (published package ${id})`);
304
+ return true;
305
+ }
306
+ async function handlePackagesOutdated(dapi, output) {
307
+ const rows = await dapi.packages.outdated();
308
+ if (!rows.length && output === 'table') {
309
+ console.log('All packages up to date.');
310
+ return true;
311
+ }
312
+ (0, _serverOutput.printOutput)(rows, output);
313
+ return true;
314
+ }
315
+ async function handlePackagesUpdate(dapi, rest, argv, output) {
316
+ const all = argv.all === true;
317
+ if (!all && !rest.length) {
318
+ (0, _serverOutput.printError)(new Error('Usage: grok s packages update <name> [<name> ...] | --all'));
319
+ return false;
320
+ }
321
+ const outdated = await dapi.packages.outdated();
322
+ const results = [];
323
+ // a package tracking 'latest' keeps that auto-update intent; a pinned one gets
324
+ // pinned to the concrete registry-latest version
325
+ const targetVersion = o => o.desiredVersion === 'latest' ? 'latest' : o.latest;
326
+ let targets;
327
+ if (all) {
328
+ targets = outdated.map(o => ({
329
+ name: o.name,
330
+ version: targetVersion(o)
331
+ }));
332
+ if (!targets.length && output === 'table') {
333
+ console.log('All packages up to date.');
334
+ return true;
335
+ }
336
+ } else {
337
+ targets = [];
338
+ for (const r of rest) {
339
+ const raw = String(r);
340
+ let pkg = null;
341
+ try {
342
+ pkg = await dapi.packages.resolve(raw);
343
+ } catch (err) {
344
+ results.push({
345
+ package: raw,
346
+ version: '',
347
+ status: 'error',
348
+ error: (err?.message ?? String(err)).replace(/\s*\n\s*/g, '; ')
349
+ });
350
+ continue;
351
+ }
352
+ if (!pkg) {
353
+ results.push({
354
+ package: raw,
355
+ version: '',
356
+ status: 'error',
357
+ error: 'Package not found'
358
+ });
359
+ continue;
360
+ }
361
+ const o = outdated.find(x => x.name === pkg.name);
362
+ if (o) {
363
+ targets.push({
364
+ name: pkg.name,
365
+ version: targetVersion(o)
366
+ });
367
+ continue;
368
+ }
369
+ const versions = Array.isArray(pkg.publishedVersions) ? pkg.publishedVersions : [];
370
+ const installed = versions.some(v => v?.isCurrent && !v?.debug);
371
+ results.push({
372
+ package: pkg.name,
373
+ version: '',
374
+ status: 'noop',
375
+ note: installed ? 'already up to date' : 'not installed'
376
+ });
377
+ }
378
+ }
379
+ results.push(...(await installPackages(dapi, targets, output)));
380
+ printPackageOps(results, output);
381
+ return true;
382
+ }
171
383
  async function handleTablesUpload(dapi, rest, output) {
172
384
  const [name, localPath] = rest;
173
385
  if (!name || !localPath) {
@@ -699,6 +911,14 @@ Special commands:
699
911
  grok s users unblock <id-or-login> Unblock a previously blocked user
700
912
  grok s tables upload <name> <file.csv|file.d42> Upload a CSV or d42 binary as a Datagrok table
701
913
  grok s tables download <name-or-id> [-O <file>] Download a table as CSV (stdout by default)
914
+ grok s packages install <name>... [--version <v>] Install latest (or pinned) versions from the registry
915
+ grok s packages uninstall <name> Uninstall a package (repository entry is kept)
916
+ grok s packages update <name>... | --all Upgrade to the latest registry version
917
+ grok s packages outdated Show installed vs latest registry versions
918
+ grok s packages versions <name> List published versions (current/latest/debug flags)
919
+ grok s packages set-version <name> <version> Activate a specific published version
920
+ grok s packages share <name> <group>[,...] [--access View|Edit]
921
+ Share a package with one or more groups
702
922
  grok s batch <entity> <verb> arg1 [arg2 ...] Batch operation (one round-trip)
703
923
  grok s batch <entity> <verb> --json params.json Batch from JSON array
704
924
  grok s batch manifest.json Run a workflow manifest
@@ -719,6 +939,8 @@ Options:
719
939
  --type <t> Function discriminator: script | query | function | package
720
940
  --language <lang> Script language: python, r, julia, nodejs, octave, grok
721
941
  --package <name> Restrict to functions belonging to a package (by short name)
942
+ --version <v> Package version for 'packages install' (default: latest)
943
+ --all For 'packages update': upgrade every outdated package
722
944
 
723
945
  Batch manifest options (in manifest.json):
724
946
  stopOnError Stop on first failure (default: true)
@@ -745,6 +967,11 @@ Examples:
745
967
  grok s functions list --package PowerPack --type package --filter 'name contains "grid"'
746
968
  grok s files list "System:AppData" -r
747
969
  grok s files put ./smiles.csv "System:DemoFiles/smiles.csv"
970
+ grok s packages install Chem Bio PowerGrid
971
+ grok s packages install Chem --version 1.14.0
972
+ grok s packages update --all
973
+ grok s packages versions Chem
974
+ grok s packages share Chem Chemists --access View
748
975
  grok s raw GET /api/users/current
749
976
  grok s describe connections
750
977
  grok s users list --host dev
package/bin/grok.js CHANGED
@@ -2,6 +2,8 @@
2
2
  const argv = require('minimist')(process.argv.slice(2), {
3
3
  alias: {k: 'key', h: 'help', r: 'recursive'},
4
4
  boolean: ['dartium'],
5
+ // keep versions verbatim — minimist would coerce '1.10' to the number 1.1
6
+ string: ['version'],
5
7
  });
6
8
  // minimist maps `--no-retry` to `{retry: false}`, so the `args['no-retry']` checks in
7
9
  // test.ts / playwright-runner.ts never fired and `--no-retry` was silently ignored
@@ -3,7 +3,7 @@
3
3
  Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
- exports.NodeUsersDataSource = exports.NodeTablesDataSource = exports.NodeSharesDataSource = exports.NodeHttpDataSource = exports.NodeGroupsDataSource = exports.NodeFuncsDataSource = exports.NodeFilesDataSource = exports.NodeDapi = exports.NodeConnectionsDataSource = exports.NodeApiClient = void 0;
6
+ exports.NodeUsersDataSource = exports.NodeTablesDataSource = exports.NodeSharesDataSource = exports.NodePackagesDataSource = exports.NodeHttpDataSource = exports.NodeGroupsDataSource = exports.NodeFuncsDataSource = exports.NodeFilesDataSource = exports.NodeDapi = exports.NodeConnectionsDataSource = exports.NodeApiClient = void 0;
7
7
  exports.ensureBodyId = ensureBodyId;
8
8
  var _crypto = require("crypto");
9
9
  /// Docs: [Grok Dapi](/docs/plans/grok-dapi/)
@@ -400,6 +400,101 @@ function tryParseJson(s) {
400
400
  return null;
401
401
  }
402
402
  }
403
+ class NodePackagesDataSource extends NodeHttpDataSource {
404
+ constructor(client) {
405
+ super(client, 'packages');
406
+ }
407
+
408
+ /** GET /packages (internal API) — includes publishedVersions and repository. */
409
+ async listFull(text) {
410
+ return this.client.get(`/packages${buildQuery({
411
+ text
412
+ })}`);
413
+ }
414
+
415
+ /** Resolve by UUID, name, or friendlyName (case-insensitive). Returns null when
416
+ not found so callers can pass the raw string through and let the server's own
417
+ 'Package not found' surface. */
418
+ async resolve(idOrName) {
419
+ const all = await this.listFull();
420
+ if (UUID_RE.test(idOrName)) return all.find(p => p?.id === idOrName) ?? null;
421
+ const want = idOrName.toLowerCase();
422
+ const matches = all.filter(p => (p?.name ?? '').toLowerCase() === want || (p?.friendlyName ?? '').toLowerCase() === want);
423
+ if (matches.length > 1) {
424
+ const list = matches.map(p => ` ${p.id} ${p.name}`).join('\n');
425
+ throw new Error(`Multiple packages match '${idOrName}':\n${list}\nUse the ID to disambiguate.`);
426
+ }
427
+ return matches[0] ?? null;
428
+ }
429
+
430
+ /** Install/activate via the DeployPackageVersion server func: pulls the version
431
+ from the package repository (npm) when needed, then makes it current.
432
+ 'latest' marks the package for server-side auto-update. Synchronous — returns
433
+ the new published-package id, or null when nothing changed. */
434
+ async install(name, desiredVersion = 'latest') {
435
+ const result = await new NodeFuncsDataSource(this.client, 'functions').run('DeployPackageVersion', {
436
+ name,
437
+ desiredVersion
438
+ });
439
+ const value = result && typeof result === 'object' ? result.result ?? result.id ?? null : result;
440
+ const text = typeof value === 'string' ? value.replace(/^"|"$/g, '') : '';
441
+ return text && text !== 'null' ? text : null;
442
+ }
443
+ async uninstall(idOrName) {
444
+ const pkg = await this.resolve(idOrName);
445
+ if (!pkg) throw new Error(`Package '${idOrName}' not found`);
446
+ await this.client.del(`/packages/${encodeURIComponent(pkg.id)}`);
447
+ return {
448
+ id: pkg.id,
449
+ name: pkg.name,
450
+ repoBacked: !!pkg.repository
451
+ };
452
+ }
453
+ async versions(idOrName) {
454
+ const pkg = await this.resolve(idOrName);
455
+ if (!pkg) throw new Error(`Package '${idOrName}' not found`);
456
+ return Array.isArray(pkg.publishedVersions) ? pkg.publishedVersions : [];
457
+ }
458
+ async outdated() {
459
+ const all = await this.listFull();
460
+ const rows = [];
461
+ for (const p of all) {
462
+ const versions = Array.isArray(p?.publishedVersions) ? p.publishedVersions : [];
463
+ // isLocal is not reliably serialized, so 'installed' = has a current non-debug version
464
+ const current = versions.find(v => v?.isCurrent && !v?.debug);
465
+ const latest = versions.find(v => v?.isLatest);
466
+ // Numeric compare so locally published builds (1.2.3.X-suffix) don't count
467
+ // as outdated against an equal or older registry version.
468
+ if (current && latest && NodePackagesDataSource.compareVersions(latest.version, current.version) > 0) rows.push({
469
+ name: p.name,
470
+ installed: current.version,
471
+ latest: latest.version,
472
+ desiredVersion: p.desiredVersion ?? ''
473
+ });
474
+ }
475
+ return rows;
476
+ }
477
+
478
+ /** Compare the leading numeric dot-groups of two version strings (suffixes like
479
+ '.X-4e32bb91' are ignored): positive when a > b. */
480
+ static compareVersions(a, b) {
481
+ const nums = s => (String(s).match(/^\d+(\.\d+)*/)?.[0] ?? '').split('.').map(Number);
482
+ const av = nums(a),
483
+ bv = nums(b);
484
+ for (let i = 0; i < Math.max(av.length, bv.length); i++) {
485
+ const d = (av[i] ?? 0) - (bv[i] ?? 0);
486
+ if (d !== 0) return d;
487
+ }
488
+ return 0;
489
+ }
490
+
491
+ /** The public API has no DELETE for packages — route to the internal endpoint. */
492
+ async delete(idOrEntity) {
493
+ const id = typeof idOrEntity === 'string' ? idOrEntity : idOrEntity?.id ?? '';
494
+ await this.client.del(`/packages/${encodeURIComponent(id)}`);
495
+ }
496
+ }
497
+ exports.NodePackagesDataSource = NodePackagesDataSource;
403
498
  class NodeFilesDataSource {
404
499
  constructor(client) {
405
500
  this.client = client;
@@ -538,7 +633,7 @@ class NodeDapi {
538
633
  return new NodeHttpDataSource(this.client, 'scripts');
539
634
  }
540
635
  get packages() {
541
- return new NodeHttpDataSource(this.client, 'packages');
636
+ return new NodePackagesDataSource(this.client);
542
637
  }
543
638
  get reports() {
544
639
  return new NodeHttpDataSource(this.client, 'reports');
@@ -82,6 +82,49 @@ function rowsToCsv(rows) {
82
82
  data: rows.map(r => header.map(h => r[h]))
83
83
  });
84
84
  }
85
+ function writePlaywrightCsv(pkgDir, csv) {
86
+ // Persist a Playwright-only CSV so the pipeline can ship it to the Datlas
87
+ // 'playwright' bucket, separate from the merged Puppeteer+Playwright
88
+ // test-report.csv that feeds the legacy 'package' bucket and JUnit.
89
+ try {
90
+ _fs.default.writeFileSync(_path.default.join(pkgDir, 'test-report-playwright.csv'), csv, 'utf-8');
91
+ } catch (e) {
92
+ color.warn(`Playwright: failed to write test-report-playwright.csv: ${e.message || e}`);
93
+ }
94
+ }
95
+
96
+ // A pass that dies before running specs still has to report a failure. Returning an empty CSV
97
+ // let the caller drop it whenever the Puppeteer pass had rows, so a suite that never launched
98
+ // (a config that fails to load, an unreachable host) was reported as a clean run.
99
+ function runnerFailure(pkgDir, args, message) {
100
+ const rows = [{
101
+ date: new Date().toISOString(),
102
+ category: 'playwright',
103
+ name: 'Playwright suite',
104
+ success: false,
105
+ result: message,
106
+ ms: 0,
107
+ skipped: false,
108
+ logs: '',
109
+ owner: '',
110
+ package: process.env.TARGET_PACKAGE || args.package || '',
111
+ widgetsDifference: '',
112
+ flaking: false
113
+ }];
114
+ const csv = rowsToCsv(rows);
115
+ writePlaywrightCsv(pkgDir, csv);
116
+ color.error(`Playwright: ${message}`);
117
+ return {
118
+ failed: true,
119
+ passedAmount: 0,
120
+ failedAmount: 1,
121
+ skippedAmount: 0,
122
+ verbosePassed: '',
123
+ verboseSkipped: '',
124
+ verboseFailed: `Playwright: ${message}\n`,
125
+ csv: csv
126
+ };
127
+ }
85
128
  async function runPlaywrightTests(pkgDir, testDir, args, hostKey) {
86
129
  const empty = {
87
130
  failed: false,
@@ -101,25 +144,13 @@ async function runPlaywrightTests(pkgDir, testDir, args, hostKey) {
101
144
  key
102
145
  } = testUtils.getDevKey(hostKey));
103
146
  } catch (e) {
104
- color.error(`Playwright: cannot resolve host '${hostKey}': ${e.message || e}`);
105
- return {
106
- ...empty,
107
- failed: true,
108
- failedAmount: 1,
109
- verboseFailed: `Playwright: ${e.message || e}\n`
110
- };
147
+ return runnerFailure(pkgDir, args, `cannot resolve host '${hostKey}': ${e.message || e}`);
111
148
  }
112
149
  let token;
113
150
  try {
114
151
  token = await testUtils.getToken(url, key);
115
152
  } catch (e) {
116
- color.error(`Playwright: cannot exchange dev key for token: ${e.message || e}`);
117
- return {
118
- ...empty,
119
- failed: true,
120
- failedAmount: 1,
121
- verboseFailed: `Playwright: ${e.message || e}\n`
122
- };
153
+ return runnerFailure(pkgDir, args, `cannot exchange dev key for token: ${e.message || e}`);
123
154
  }
124
155
  let webUrl;
125
156
  try {
@@ -137,15 +168,7 @@ async function runPlaywrightTests(pkgDir, testDir, args, hostKey) {
137
168
  }
138
169
  }
139
170
  const configPath = _path.default.join(testDir, 'playwright.config.ts');
140
- if (!_fs.default.existsSync(configPath)) {
141
- color.error(`Playwright: ${configPath} not found.`);
142
- return {
143
- ...empty,
144
- failed: true,
145
- failedAmount: 1,
146
- verboseFailed: 'Playwright: missing playwright.config.ts\n'
147
- };
148
- }
171
+ if (!_fs.default.existsSync(configPath)) return runnerFailure(pkgDir, args, `${configPath} not found`);
149
172
  const reportFile = _path.default.join(pkgDir, 'test-playwright-report.json');
150
173
  if (_fs.default.existsSync(reportFile)) _fs.default.unlinkSync(reportFile);
151
174
  const cliArgs = ['--no-install', 'playwright', 'test', `--config=${configPath}`];
@@ -219,20 +242,18 @@ async function runPlaywrightTests(pkgDir, testDir, args, hostKey) {
219
242
  } catch {/* ignore */}
220
243
  }
221
244
  if (!report) {
222
- color.error('Playwright: no JSON report produced.');
223
245
  const tail = Buffer.concat(stderrChunks).toString('utf-8').slice(-2000);
224
- return {
225
- ...empty,
226
- failed: true,
227
- failedAmount: 1,
228
- verboseFailed: `Playwright: no JSON report. stderr tail:\n${tail}\n`
229
- };
246
+ return runnerFailure(pkgDir, args, `no JSON report produced — the suite never ran. stderr tail:\n${tail}`);
230
247
  }
231
248
  const pkgJson = JSON.parse(_fs.default.readFileSync(_path.default.join(pkgDir, 'package.json'), 'utf-8'));
232
249
  const owner = pkgJson.author && (pkgJson.author.email || pkgJson.author) || '';
233
250
  const pkgName = process.env.TARGET_PACKAGE || args.package || pkgJson.name || '';
234
251
  const rows = [];
235
252
  flattenSuites(report.suites, testDir, pkgName, typeof owner === 'string' ? owner : '', args.verbose === true, rows);
253
+
254
+ // A report with no specs is the same non-event as no report at all: the config loaded but
255
+ // collection found nothing, or died on a spec that cannot be imported.
256
+ if (rows.length === 0) return runnerFailure(pkgDir, args, 'the run produced a report with no specs — check the spec imports and testMatch');
236
257
  let passedAmount = 0;
237
258
  let failedAmount = 0;
238
259
  let skippedAmount = 0;
@@ -253,14 +274,7 @@ async function runPlaywrightTests(pkgDir, testDir, args, hostKey) {
253
274
  }
254
275
  }
255
276
  const csv = rowsToCsv(rows);
256
- // Persist a Playwright-only CSV so the pipeline can ship it to the Datlas
257
- // 'playwright' bucket, separate from the merged Puppeteer+Playwright
258
- // test-report.csv that feeds the legacy 'package' bucket and JUnit.
259
- try {
260
- _fs.default.writeFileSync(_path.default.join(pkgDir, 'test-report-playwright.csv'), csv, 'utf-8');
261
- } catch (e) {
262
- color.warn(`Playwright: failed to write test-report-playwright.csv: ${e.message || e}`);
263
- }
277
+ writePlaywrightCsv(pkgDir, csv);
264
278
  return {
265
279
  failed: failedAmount > 0,
266
280
  passedAmount: passedAmount,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "datagrok-tools",
3
- "version": "6.5.5",
3
+ "version": "6.5.7",
4
4
  "description": "Utility to upload and publish packages to Datagrok",
5
5
  "homepage": "https://github.com/datagrok-ai/public/tree/master/tools#readme",
6
6
  "dependencies": {