@malloydata/malloyyo 0.2.28 → 0.2.29
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.
- package/dist/index.js +423 -190
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -99,8 +99,8 @@ function resolveTarget(dir, name) {
|
|
|
99
99
|
}
|
|
100
100
|
function resolveInstance(dir, arg) {
|
|
101
101
|
if (arg && /^https?:\/\//i.test(arg)) {
|
|
102
|
-
const
|
|
103
|
-
return { name:
|
|
102
|
+
const url6 = normalizeUrl(arg);
|
|
103
|
+
return { name: url6, url: url6 };
|
|
104
104
|
}
|
|
105
105
|
const targets = readTargetMap(dir);
|
|
106
106
|
const entries = Object.entries(targets);
|
|
@@ -125,9 +125,9 @@ import { join as join2, relative, sep } from "node:path";
|
|
|
125
125
|
import { execFileSync } from "node:child_process";
|
|
126
126
|
|
|
127
127
|
// src/host.ts
|
|
128
|
-
import
|
|
129
|
-
import
|
|
130
|
-
import
|
|
128
|
+
import fs2 from "node:fs";
|
|
129
|
+
import path3 from "node:path";
|
|
130
|
+
import url3 from "node:url";
|
|
131
131
|
import {
|
|
132
132
|
MalloyConfig,
|
|
133
133
|
Runtime,
|
|
@@ -153,7 +153,7 @@ var contentFiles = {
|
|
|
153
153
|
"dashboards/givens-and-controls.md": "---\ndescription: Dashboard filter controls \u2014 declare filter<T> givens with # label / suggest / control tags; faceted (related) suggestions\n---\n\n# Dashboard givens & controls\n\nA dashboard's filters are `filter<T>` **givens** declared in the model; the\n`#` tags on each declaration drive its control. This is part of authoring a\ndashboard \u2014 see also `yo_help dashboards/authoring`.\n\n**Declare the filters as `filter<T>` givens** \u2014 never raw strings/numbers.\nA `filter<string>` value accepts one value ('NY'), alternatives ('NY, CA'),\nwildcards ('Ann%'), negation ('-NY'); a `filter<number>` accepts ranges\n('[1910 to 1930]') and comparisons ('> 200'); a `filter<timestamp>` /\n`filter<date>` accepts relative windows ('7 days' = the last 7 days, 'today',\n'last month') and literal ranges ('2026-01-01 to 2026-07-01' \u2014 NO `@` in\nfilter literals). Apply with `~`; `f''` = empty = no filter (the natural\n\"All\"/\"all time\" \u2014 just `col ~ $X`, no `$X = '' or \u2026` dance):\n\n```malloy\n##! experimental { givens }\ngiven:\n # label=\"State\" control=select suggest { source=baby_names dimension=state }\n STATE :: filter<string> is f'NY'\n # label=\"Brand\" suggest { query=brand_suggest dimension=product_brand }\n BRAND :: filter<string> is f''\n # label=\"Names\" control=multiselect suggest { query=name_suggest dimension=name }\n NAMES :: filter<string> is f''\n # label=\"Years\" range_min=1910 range_max=2025\n YEAR_RANGE :: filter<number> is f'[1910 to 1930]'\n # label=\"Time period\"\n PERIOD :: filter<timestamp> is f''\n # label=\"Include rare names\"\n INCLUDE_RARE :: boolean is false\n```\n\nTags on the declaration drive the control (tag syntax is `key=\"value\"` \u2014\nequals, not colon):\n- `label` \u2014 control caption (defaults to the given's name)\n- `suggest { \u2026 }` \u2014 where the control's options come from. NO Malloy code in\n strings \u2014 just names:\n - `suggest { query=brand_suggest dimension=product_brand }` \u2014 the FIRST\n COLUMN of a named query (declare the query in the model \u2014 governed and\n reviewable). PREFER THIS FORM. The query must be in scope where the dashboard\n runs (bring it in with the dashboard file's bare `import`).\n - `suggest { source=baby_names dimension=state }` \u2014 the DISTINCT VALUES of\n a dimension on a source (the source must be in the dashboard's scope)\n A `dimension` (in either form) is what enables SERVER-SIDE TYPEAHEAD: the\n runtime refines the base query with what the user has typed\n (`\u2026 + { where: lower(field) ~ f'll%'; limit: 50 }`, case-insensitive,\n escaped). Without a dimension the fetched list is filtered client-side.\n Runs as a restricted query; lint checks the declaration compiles.\n\n **RELATED (faceted) filters** \u2014 query-form only: a suggest query may\n reference the OTHER givens, and the runtime runs it with the dashboard's\n current values (the suggested given itself is excluded, so the list never\n collapses to the current pick). Brand suggestions narrow when Category is\n set:\n\n ```malloy\n query: brand_suggest is inventory_items -> product_brand + {\n where:\n product_category ~ $CATEGORY, // NOT product_brand ~ $BRAND\n product_department ~ $DEPARTMENT\n limit: 500\n }\n ```\n\n Declare one `*_suggest` per filter, each referencing the others; `f''`\n defaults mean unset filters don't constrain. `source=` suggests can't do\n this (no place for a `where:`) \u2014 another reason to prefer `query=`.\n- `control=select` \u2014 a fixed dropdown instead of a typeahead search box\n- `control=multiselect` \u2014 a tokenized multi-select for a `filter<string>`:\n each pick is a removable chip, the committed value is an exact-match list\n (`Emma, Olivia, Sophia`). Ideal for \"pick several\" filters (names, brands).\n Suggestions come from the given's `suggest {\u2026}` (server-side typeahead when\n it names a dimension). Empty (start from `f''`) = no filter (all).\n- `range_min` / `range_max` \u2014 bounds; makes a filter<number> given a\n dual-thumb range slider\n- anything else passes through in `spec.tags` for custom components\n\nControl picked from the declaration automatically: numeric range tags \u2192\ndual-thumb slider; `filter<timestamp|timestamptz|date>` \u2192 the TimeRange\nwidget (relative presets: Today / Last 7 days / Last 30 days / \u2026 plus a\n\"Custom range\u2026\" from/to date picker); `control=multiselect` \u2192 chip\nmulti-select; suggest + control=select \u2192 dropdown; boolean \u2192 checkbox;\nanything else \u2192 committing search box with typeahead (an inline \u2715 clears it;\na \"Press \u21B5 to apply\" hint shows while the typed draft differs from what's\nrunning \u2014 free text can't safely re-run per keystroke).\nThe suggest-driven options are DATA VALUES only \u2014 options that aren't column\nvalues (custom time presets, threshold buckets) need a custom component\n(`yo_help dashboards/custom-components`) with explicit `{value, text}` options\nwhere value is a filter expression built with `filters.*`.\n\n## When the query re-runs: live (default) vs. Apply\n\nBy default a dashboard is **live** \u2014 every control change re-runs the query\nimmediately (the committing search box is the exception: free text commits on\nEnter/blur, since a half-typed filter is invalid). To batch changes behind an\n**Apply** button instead, set `autorun=false` on the `# artifact` tag:\n\n```malloy\n# artifact { name=\"births-by-name\" title=\"Births by name\" autorun=false }\n```\n\n`autorun=false` makes `<Controls>` grow an Apply/Reset pair \u2014 controls edit a\ndraft and nothing re-runs until Apply. Reach for it when the query is expensive\nor several filters are usually changed together; leave it off (live) otherwise.\n",
|
|
154
154
|
"dashboards/grid-layout.md": "---\ndescription: Dashboard grid layout \u2014 # dashboard {columns=N} with # colspan and # break to place KPI tiles and charts\n---\n\n# Dashboard grid layout (`# dashboard {columns=N}`)\n\nBy default a `# dashboard` result flows its KPI tiles and cards and wraps.\nAdd `{columns=N}` to place them on a fixed **N-column grid** instead \u2014 use\n`columns=6`, which divides evenly into 2- and 3-wide cards.\n\n**Key mechanic:** a tag placed ABOVE `aggregate:` or `nest:` applies to EVERY\nitem declared in that block. So you set card widths once per block, not per\nfield.\n\n```malloy\n# artifact { title=\"Customer Insights\" } dashboard {columns=6}\nview: customer_insights is {\n where: created_at ~ $PERIOD\n # colspan=2\n aggregate: total_sales, user_count, order_count, average_order_value\n # colspan=3\n nest:\n # break\n # bar_chart\n users_by_spend_tier\n sales_by_traffic_source\n # shape_map\n sales_by_state\n # colspan=6\n recent_orders // wide detail table \u2192 full width\n}\n```\n\n## The conventions\n\n- **`# colspan=2` above `aggregate:`** \u2014 each KPI / measure tile spans 2 of 6\n columns \u2192 3 tiles per row.\n- **`# colspan=3` above `nest:`** \u2014 each graph or small table spans 3 \u2192 2 per\n row. Per-item render tags (`# line_chart`, `# bar_chart`, `# shape_map`) still\n go on the individual nested items.\n- **`# colspan=6`** \u2014 a single wide / many-column table gets its own full-width\n line. Tag that one item; a per-item `# colspan` overrides the block default.\n- **`# break` on the FIRST nest item** \u2014 starts the graphs on a fresh row, so\n KPI tiles and charts never share one. The renderer splits fields into a new\n grid at each `# break`. Just always add it: it's a no-op when the tiles\n already fill complete rows, and the fix when they don't (e.g. 4 measures\n leave a lone tile a colspan-3 chart would otherwise pack in beside).\n\n`# colspan` only does anything in columns mode \u2014 without `{columns=N}` the\nlayout is free-flow wrap and colspan is ignored. Clamp colspans to `1..N`.\n\nSee also `yo_help dashboards/vega-charts` for custom charts, and the fuller\nauthoring guide surfaced by the local `malloyyo mcp` server.\n",
|
|
155
155
|
"dashboards/vega-charts.md": '---\ndescription: Custom dashboard charts with Vega-Lite \u2014 the <VegaChart> component, for charts the # renderer tags can\'t do\n---\n\n# Custom charts with Vega-Lite (`<VegaChart>`)\n\nWhen Malloy\'s renderer tags (`# bar_chart`, `# line_chart`, `# shape_map`, \u2026)\ndon\'t cover the chart you want, a dashboard can draw a **Vega-Lite** spec with\nthe `<VegaChart>` component. The chart engine ships in the dashboard runtime, so\nyou author only a JSON spec + a Malloy query \u2014 no library to load.\n\n**It is a COMPONENT, not a `#` tag.** There is no `# vega_lite` or\n`# scatter_chart` tag. `<VegaChart>` lives in a custom component \u2014 a flat sibling\n`dashboards/<name>.jsx` (or `.tsx`) next to the dashboard\'s\n`dashboards/<name>.malloy` \u2014 a different layer from the `#` renderer tags. (The\ndashboard\'s query is declared in the `.malloy` file; the component only\ncustomizes presentation. Preview with `malloyyo dashboard dev`, validate with\n`malloyyo lint`.)\n\n## The recipe\n\n```tsx\nimport { VegaChart } from "@malloyyo/dashboard";\n\n// Encodings point at the query\'s OUTPUT COLUMN NAMES (here: name, births).\nconst spec = {\n mark: { type: "bar", tooltip: true },\n encoding: {\n y: { field: "name", type: "nominal", sort: "-x" },\n x: { field: "births", type: "quantitative" },\n },\n};\n\nexport default function Dashboard({ givens }) {\n return <VegaChart spec={spec} query="births_by_name" givens={givens} />;\n}\n```\n\nThree ways to feed it data:\n- `<VegaChart spec={spec} query="births_by_name" givens={givens}/>` \u2014 a query\n defined in the dashboard\'s `.malloy` file (by name), or a `source -> view`\n- `<VegaChart spec={spec} malloy="source -> view" givens={givens}/>` \u2014 restricted\n Malloy text (same governance as the explore surface: no import / given: /\n connection.* / raw SQL / ##! flags)\n- `<VegaChart spec={spec} data={rows}/>` \u2014 rows you already have from `useQuery`\n\n## Gotchas (the ones that actually bite)\n\n- **Shape the data in Malloy; return FLAT rows.** Do ranking, share/percent\n (`all(x, dim)`), and label lookups (a `pick` for month names) in the QUERY.\n The spec just encodes columns \u2014 it is not the place to reshape data.\n- **Match column names character-for-character.** Run the query once with\n `query(execute:true)` and read the exact output column names; the spec\'s\n `field` values must match them exactly.\n- **The spec\'s `data` is ignored / any `url` is stripped.** The frame has no\n network \u2014 remote data URLs, transform lookups, and remote `image` marks are\n removed. Adapting a Vega-Lite gallery example = delete its\n `"data": {"url": \u2026}` and repoint the encodings; the query rows are inlined for\n you as the dataset.\n- **Nests come back as arrays.** Flatten to plottable rows in the query, or bind\n a nest to its own chart: `<VegaChart data={row.my_nest}/>`.\n- **Huge integers arrive as strings, on purpose.** Integers serialize as JSON\n numbers, but a value beyond \xB12^53 (a snowflake ID, a 64-bit hash) keeps its\n full precision by staying a string \u2014 JSON has no int64. Bound to a\n `quantitative` or `temporal` channel, Vega-Lite would sort such a column\n lexicographically (`"1","10","11","2"`). If a column can get that big and you\n need to plot it, narrow it in the QUERY \u2014 bucket it, rank it, or emit the\n value you actually want on the axis.\n- **Interactivity = setting given values**, never rewriting query text per\n interaction. Client-side chart interactions (tooltip, zoom, brush) work;\n anything that calls a server does not.\n- **Reads well:** for normalized/share data use a diverging color scale with\n `domainMid` (e.g. `1/12` for month-share), and sort a discrete axis by a\n companion numeric field (`month_name` sorted by `month_num`) rather than\n alphabetically.\n\n## Validate\n\n`malloyyo lint` checks the query, the givens, AND the component (it compiles,\nand each `query="\u2026"` it references resolves) \u2014 your only pre-browser check. Then\n`malloyyo dashboard dev` to see it render.\n',
|
|
156
|
-
"develop/connection-setup.md": '---\ndescription: Setting up a data connection (malloy-config.json)\n---\n\n# Setting up a data connection\n\nA model reaches its data through a **connection** declared in\n`malloy-config.json` at the **root of the model** (next to `index.malloy`). This\nis Malloy\'s standard connection config \u2014 the full reference, with every\nconnector\'s properties, is at\n<https://docs.malloydata.dev/documentation/setup/config>. The essentials:\n\n## The file\n\n```json\n{\n "connections": {\n "mydb": { "is": "duckdb" }\n }\n}\n```\n\n- A connection has a **name** (the key) and a type (`is`). Sources refer to it by\n that name \u2014 `source: x is mydb.table("orders")` or `mydb.sql("SELECT \u2026")` \u2014 so\n the name in the config must match the name in the model.\n- Supported types (`is`): `duckdb` (incl. MotherDuck), `bigquery`, `postgres`,\n `mysql`, `snowflake`, `databricks`, `trino`, `presto`. Each has its own\n properties \u2014 see the full docs.\n\n## Default connections (and when they apply)\n\nSetting `"includeDefaultConnections": true` makes one connection available for\n**each registered database type, named by the type** \u2014 a `duckdb` connection\nnamed `duckdb`, a `postgres` named `postgres`, and so on. Each uses that\nconnector\'s default settings, which for several backends means picking up\ncredentials from the environment (e.g. BigQuery\'s application-default\ncredentials \u2014 see the per-connector setup docs). Connections you name explicitly\nin `connections` always win; the defaults only fill in types you didn\'t list.\n\n```json\n{\n "includeDefaultConnections": true,\n "connections": { "warehouse": { "is": "postgres", "host": "\u2026" } }\n}\n```\n\nmalloyyo has one rule worth knowing:\n\n- **No `malloy-config.json` at all \u2192 defaults are ON.** `duckdb` just works with\n zero setup.\n- **Write a `malloy-config.json` and they turn OFF** unless you add\n `"includeDefaultConnections": true`. A config that only defines, say, a\n `postgres` connection will report *No connection named "duckdb"* if a source\n still references `duckdb`.\n\nThis is deliberate \u2014 your local connections then resolve exactly the way the\npublished server\'s will, rather than silently leaning on a default that would not\nexist in production. (It differs from `malloy-cli`, which forces the defaults on\nunconditionally.)\n\nGive a connection a **custom name** \u2014 not the bare type default \u2014 whenever you\nhave more than one connection of the same type, or need non-default parameters.\n\n## DuckDB and local files (the common case)\n\nDuckDB can either open a **DuckDB database file** or read **local data files**\n(CSV, Parquet, \u2026) directly.\n\n**A pre-loaded database file** \u2014 point `databasePath` at a `.duckdb` file and\nreference its tables by name (an absolute path is safest):\n\n```json\n{ "connections": { "warehouse": { "is": "duckdb", "databasePath": "/data/warehouse.duckdb" } } }\n```\n\n**Local files, by path** \u2014 read a CSV or Parquet file straight into a source.\nThese paths are **project-relative** \u2014 resolved against the model root, so they\nsurvive publishing:\n\n source: my_csv is duckdb.table(\'data/my_file.csv\')\n source: my_parquet is duckdb.table(\'data/my_file.parquet\')\n\n`.table()` names a single file. When you need something it can\'t express \u2014 a\nglob, a union, any SQL \u2014 wrap it in `.sql()` (its paths are project-relative too):\n\n source: payments is duckdb.sql(\n "SELECT * FROM read_parquet(\'data/payments-*.parquet\')"\n )\n\nThe default `duckdb` connection is **in-memory** \u2014 nothing persists between runs;\nyour data lives in the files (or the `databasePath` database) you read.\n\n**MotherDuck:** a DuckDB connection against an `md:` database; set the\n`MOTHERDUCK_TOKEN` environment variable.\n\n## Secrets \u2014 keep them out of the file\n\nAny property value may be written as `{ "env": "VAR_NAME" }`. It resolves from\n`process.env.VAR_NAME` when the connection opens, so passwords and tokens never\nget committed:\n\n```json\n{\n "connections": {\n "analytics": {\n "is": "postgres",\n "host": "db.internal",\n "databaseName": "analytics",\n "username": "reader",\n "password": { "env": "PG_PASSWORD" }\n }\n }\n}\n```\n\n(The non-secret property names here are illustrative \u2014 each connector\'s exact\nproperties are in the full docs. The `{ "env": \u2026 }` form is the part that\nmatters: it works for any value.)\n\n## malloyyo specifics\n\n- **One file, one place.** Only the `malloy-config.json` at the model root is\n read \u2014 there is no walk-up to parent directories.\n- **Local override:** a `malloy-config-local.json` (do **not** commit it)\n **replaces** `malloy-config.json` entirely when present \u2014 your private variant\n for local credentials or a different database.\n- **The same file ships to production.** Publishing uploads this exact\n `malloy-config.json`, so it must resolve the same way locally and on the\n server \u2014 put anything environment-specific behind `{ "env": \u2026 }` rather than\n hard-coding it. That is what makes the local test window faithful to\n production.\n- Edits are picked up **without a restart** \u2014 the server re-reads the file when\n it changes.\n\n## When a connection will not resolve\n\nFix the connection **first**: a broken connection yields an empty schema and then\na cascade of misleading `field-not-found` errors \u2014 ignore those and fix the\nconnection. A fast check is to compile a probe and see if it alone compiles:\n\n source: _probe is mydb.sql("SELECT 1 AS one")\n',
|
|
156
|
+
"develop/connection-setup.md": '---\ndescription: Setting up a data connection (malloy-config.json)\n---\n\n# Setting up a data connection\n\nA model reaches its data through a **connection** declared in\n`malloy-config.json` at the **root of the model** (next to `index.malloy`). This\nis Malloy\'s standard connection config \u2014 the full reference, with every\nconnector\'s properties, is at\n<https://docs.malloydata.dev/documentation/setup/config>. The essentials:\n\n## The file\n\n```json\n{\n "connections": {\n "mydb": { "is": "duckdb" }\n }\n}\n```\n\n- A connection has a **name** (the key) and a type (`is`). Sources refer to it by\n that name \u2014 `source: x is mydb.table("orders")` or `mydb.sql("SELECT \u2026")` \u2014 so\n the name in the config must match the name in the model.\n- Supported types (`is`): `duckdb` (incl. MotherDuck), `bigquery`, `postgres`,\n `mysql`, `snowflake`, `databricks`, `trino`, `presto`. Each has its own\n properties \u2014 see the full docs.\n\n## Default connections (and when they apply)\n\nSetting `"includeDefaultConnections": true` makes one connection available for\n**each registered database type, named by the type** \u2014 a `duckdb` connection\nnamed `duckdb`, a `postgres` named `postgres`, and so on. Each uses that\nconnector\'s default settings, which for several backends means picking up\ncredentials from the environment (e.g. BigQuery\'s application-default\ncredentials \u2014 see the per-connector setup docs). Connections you name explicitly\nin `connections` always win; the defaults only fill in types you didn\'t list.\n\n```json\n{\n "includeDefaultConnections": true,\n "connections": { "warehouse": { "is": "postgres", "host": "\u2026" } }\n}\n```\n\nmalloyyo has one rule worth knowing:\n\n- **No `malloy-config.json` at all \u2192 defaults are ON.** `duckdb` just works with\n zero setup.\n- **Write a `malloy-config.json` and they turn OFF** unless you add\n `"includeDefaultConnections": true`. A config that only defines, say, a\n `postgres` connection will report *No connection named "duckdb"* if a source\n still references `duckdb`.\n\nThis is deliberate \u2014 your local connections then resolve exactly the way the\npublished server\'s will, rather than silently leaning on a default that would not\nexist in production. (It differs from `malloy-cli`, which forces the defaults on\nunconditionally.)\n\n*No connection named* has one cause that is **not** about your config: if more\nthan one copy of `@malloydata/malloy` is installed, the connectors register with\none copy and malloyyo reads another, and every connection looks missing. malloyyo\ndetects that and says so instead \u2014 if it doesn\'t mention duplicate copies, the\nproblem really is the config. To check by hand: `npm ls @malloydata/malloy`.\n\nGive a connection a **custom name** \u2014 not the bare type default \u2014 whenever you\nhave more than one connection of the same type, or need non-default parameters.\n\n## DuckDB and local files (the common case)\n\nDuckDB can either open a **DuckDB database file** or read **local data files**\n(CSV, Parquet, \u2026) directly.\n\n**A pre-loaded database file** \u2014 point `databasePath` at a `.duckdb` file and\nreference its tables by name (an absolute path is safest):\n\n```json\n{ "connections": { "warehouse": { "is": "duckdb", "databasePath": "/data/warehouse.duckdb" } } }\n```\n\n**Local files, by path** \u2014 read a CSV or Parquet file straight into a source.\nThese paths are **project-relative** \u2014 resolved against the model root, so they\nsurvive publishing:\n\n source: my_csv is duckdb.table(\'data/my_file.csv\')\n source: my_parquet is duckdb.table(\'data/my_file.parquet\')\n\n`.table()` names a single file. When you need something it can\'t express \u2014 a\nglob, a union, any SQL \u2014 wrap it in `.sql()` (its paths are project-relative too):\n\n source: payments is duckdb.sql(\n "SELECT * FROM read_parquet(\'data/payments-*.parquet\')"\n )\n\nThe default `duckdb` connection is **in-memory** \u2014 nothing persists between runs;\nyour data lives in the files (or the `databasePath` database) you read.\n\n**MotherDuck:** a DuckDB connection against an `md:` database; set the\n`MOTHERDUCK_TOKEN` environment variable.\n\n## Secrets \u2014 keep them out of the file\n\nAny property value may be written as `{ "env": "VAR_NAME" }`. It resolves from\n`process.env.VAR_NAME` when the connection opens, so passwords and tokens never\nget committed:\n\n```json\n{\n "connections": {\n "analytics": {\n "is": "postgres",\n "host": "db.internal",\n "databaseName": "analytics",\n "username": "reader",\n "password": { "env": "PG_PASSWORD" }\n }\n }\n}\n```\n\n(The non-secret property names here are illustrative \u2014 each connector\'s exact\nproperties are in the full docs. The `{ "env": \u2026 }` form is the part that\nmatters: it works for any value.)\n\n## malloyyo specifics\n\n- **One file, one place.** Only the `malloy-config.json` at the model root is\n read \u2014 there is no walk-up to parent directories.\n- **Local override:** a `malloy-config-local.json` (do **not** commit it)\n **replaces** `malloy-config.json` entirely when present \u2014 your private variant\n for local credentials or a different database.\n- **The same file ships to production.** Publishing uploads this exact\n `malloy-config.json`, so it must resolve the same way locally and on the\n server \u2014 put anything environment-specific behind `{ "env": \u2026 }` rather than\n hard-coding it. That is what makes the local test window faithful to\n production.\n- Edits are picked up **without a restart** \u2014 the server re-reads the file when\n it changes.\n\n## When a connection will not resolve\n\nFix the connection **first**: a broken connection yields an empty schema and then\na cascade of misleading `field-not-found` errors \u2014 ignore those and fix the\nconnection. A fast check is to compile a probe and see if it alone compiles:\n\n source: _probe is mydb.sql("SELECT 1 AS one")\n',
|
|
157
157
|
"develop/getting-started.md": '---\ndescription: Getting started \u2014 build a Malloy model step by step\n---\n\n# Building a Malloy model, step by step\n\nA model is a `malloy-config.json` (the connection to the data) and an\n`index.malloy` (the published query surface), optionally with other `.malloy`\nfiles that `index.malloy` imports. You edit these with your own file tools; the\nMCP tools compile, inspect, and test what you wrote. Never read `.malloy` as\ntext \u2014 compiling a bare source is how you read a table\'s schema.\n\n## 1. Verify the connection first\n\nConfirm the connection named in `malloy-config.json` resolves \u2014 compile a\nthrowaway probe inline with `compile` (no file needed):\n\n source: _probe is CONN.sql("SELECT 1 AS one")\n\nIf it compiles, the connection is good. If not, fix the connection / config\nbefore going further \u2014 a broken connection produces an empty schema and then a\ncascade of misleading `field-not-found` errors; ignore the cascade and fix the\nconnection. Call `yo_help("develop/connection-setup")` for how to set up or repair a connection.\n\n## 2. Identify the tables the model needs\n\nIf you are unsure which tables matter, ask the fox \u2014 they own the data and know\nwhere it lives.\n\n## 3. Get a base source per table\n\nA base is "what\'s in the table and what\'s computable from it" \u2014 no joins.\nDiscover the schema by compiling a bare stub inline with `compile`:\n\n source: users_base is CONN.table("users")\n\n`compile` returns the full column list + types \u2014 that is your schema browser.\nThen write the base into its own file and iterate with `compile_file`, adding\nonly the dimensions and measures intrinsic to that one table:\n\n // users_base.malloy\n source: users_base is CONN.table("users") extend {\n measure: user_count is count()\n }\n\nIf the data lives in files rather than database tables \u2014 common when the\nconnection is DuckDB \u2014 DuckDB lets you name a file path as the table (a\nproject-relative path):\n\n source: users_base is CONN.table(\'data/users.parquet\')\n\nOnly drop to a `.sql()` block when a single file-as-table can\'t express what you\nneed \u2014 e.g. globbing or unioning several files:\n\n source: users_base is CONN.sql("SELECT * FROM read_parquet(\'data/users-*.parquet\')")\n\n(`read_parquet` there is DuckDB SQL, not Malloy \u2014 see `yo_help("develop/connection-setup")`.)\n\n## 4. Assemble index.malloy \u2014 the published surface\n\nImport the bases, join them into the consumer-facing sources, and explicitly\nexport what consumers may query:\n\n import "users_base.malloy"\n import "orders_base.malloy"\n\n source: users is users_base extend {\n join_many: orders is orders_base on id = orders.user_id\n }\n source: orders is orders_base extend { }\n\n export { users, orders }\n\n**Export discipline** \u2014 the model is a published artifact, so be deliberate about\nits public surface:\n\n- Imported names are private. Base sources stay internal scaffolding unless you\n export them.\n- Without an `export` statement, everything you define is public. Add one and the\n surface becomes explicit: only the names you list \u2014 defined or imported \u2014 are\n public.\n- Hide intermediates. A staging source you define only so other sources can build\n on it should not be exported.\n- The export list is the consumer\'s menu \u2014 exactly what the test window and real\n consumers can query, nothing more.\n\n## The loop\n\nEdit a file \u2192 `compile_file` \u2192 fix `problems[]` \u2192 `query` (pass the model file\'s\npath as `ref`; `execute: false` validates first and reports the givens the query\nneeds, supplied via `givens`). Query text is restricted \u2014 `import`, `given:`\ndeclarations, `connection.table`/`connection.sql`, raw SQL, and `##!` flags\nbelong in the model file, not the query. When `compile`/`compile_file` returns\n`formatted: false`, call `prettify` and save its output. Use project-relative\ndata paths, not absolute \u2014 they resolve against the project root and survive\npublishing the model.\n',
|
|
158
158
|
"develop/working-with-models.md": "---\ndescription: Working with an existing Malloy model\n---\n\n# Working with an existing model\n\nAn existing model is an `index.malloy` (plus any `.malloy` files it imports) and\na `malloy-config.json`.\n\n## Understand the model\n\n- **Read `malloy-config.json` directly** \u2014 it is JSON, so read it as text. It\n lists the connection(s) the model queries against; `yo_help(\"develop/connection-setup\")` explains\n the format (and how to set one up or repair it).\n- **Do NOT read `.malloy` as text \u2014 compile it.** `compile_file` returns the\n structured model: each source with its fields, joins, views, and named queries,\n plus `problems[]`. That is how you describe what is in the model. (Compiling a\n bare source \u2014 no `extend` block \u2014 likewise reads a raw table's schema.)\n\n## The loop\n\nEdit a file \u2192 `compile_file` \u2192 fix `problems[]` \u2192 `query`. Pass the model file's\npath as `ref`; `execute: false` validates first and reports the givens the query\nneeds (supplied via `givens`). Query text is restricted \u2014 `import`, `given:`\ndeclarations, `connection.table`/`connection.sql`, raw SQL, and `##!` flags\nbelong in the model file, not the query. When `compile`/`compile_file` returns\n`formatted: false`, run `prettify` and save its output. Use project-relative data\npaths, not absolute.\n",
|
|
159
159
|
"explore/how-to.md": '---\ndescription: Extended instructions working with this MCP server\n---\n# General Notes\nMalloy is a combined semantic layer/query language. It describes data and analysis, and it can generate and execute SQL.\n\nYou answer questions from published Malloy semantic models. A model publishes sources and queries.\n\n* Some tools return problems[] to indicate invalid Malloy. problems may have a `help_topic` field \u2014 call `yo_help(help_topic)` for detailed guidance.\n* `yo_help()` with no topic will show an index which include error explanations, examples of Malloy syntax for common patterns, and a language reference manual (Malloy syntax is still evolving).\n* Tools that inspect Malloy code return objects with schemas, among other things. An entry with a name which requires `back-tick-quoting` (reserved word, special characters), will have `must_quote: true`\n* When limiting queries, do ranking, top-N, and member selection in Malloy, not in client code. Results are byte-budgeted: oversized results are truncated (the response says so and may link the full result). Reading aggregated rows is better analysis and the only way to see everything.\n* Compose your answer from what the model publishes. When composing a query, you can make new sources, extending existing sources with measures dimensions and joins. If the model\'s surface genuinely cannot answer a question, that is useful signal about the model.\n\n# Answering A Question\nTo answer a question you need to see what sources are available which pertain to the question.\n\n`list_sources` (when available) \u2014 see the sources you can query, grouped by model, with each model\'s named queries. If you already know the source, go straight to describe_source.\n\n# Build the Query\n* New to a pattern? `yo_help("explore/query-examples")` \u2014 the handful of Malloy query shapes (views, the workhorse group_by/aggregate, filtered aggregates, `all()`, `extend:`, `select:`, `nest:`) that cover almost every question, with the SQL habits that are wrong in Malloy.\n* `describe_source(source, model_ref)` \u2014 always describe a source before querying it (`model_ref` optional when the name is unique). Returns:\n * `described_source` \u2014 the source\'s `dimensions` (columns), `measures`, and `views` (the author\'s saved queries). A dimension\'s `type` is a scalar or a nested record (`origin.city`); an array column has no `type` \u2014 it shows up as a `joins` entry at its `path`.\n * `joins` \u2014 keyed by path, the arrays and source-joins this source reaches. `fans_out` marks a path that fans out. Each entry is one of: `{ source }` (fields in `join_source_map`), `{ source_def }` (an anonymous source\'s fields, inline), or an array `{ is_array, source_def }` \u2014 a record array\'s fields are used directly (`parcels.sku`), a scalar array\'s element is `each` (`tags.each`). To write a reference, use the entry\'s `quoted_path` if it has one, else the key.\n * `join_source_map` \u2014 the named sources those `{ source }` joins resolve to, deduped.\n * In its own content block, the source\'s raw Malloy, for anything the structured output above doesn\'t cover.\n* `query(source: "...", malloy: "run: source -> { ... }", execute: false)` \u2014 validate without running; it returns the SQL. Iterate until clean. (`model_ref` optional, needed only when the source name is ambiguous.)\n* Some queries accept parameters (givens). More info if needed: yo_help("language/givens-model-level-parameters")\n\n# Run the query\n* Pass a plain-English question with EVERY query, describing what that specific query answers. Queries are recorded/shared independently. Don\'t try to group related queries.\n* query(source: "...", malloy: "run: source -> { ... }", question: "...") \u2014 run it; get the rows.\n\n# Displaying Results\n* Lead with a natural-language restatement of the query \u2014 a short heading works well.\n* A successful query comes back with an ltool_link \u2014 {text, url}, already assembled. It opens this exact query so the user can keep exploring, or share the insight. Follow the data with a markdown link, [\u2197 text](url).\n* When it helps the reader add a short note on how you got the answer: the Malloy logic (filters, grouping, aggregation, ordering, pipeline stages), and any post-processing done outside Malloy.',
|
|
@@ -330,8 +330,8 @@ function errorProblem(e, uri) {
|
|
|
330
330
|
uri
|
|
331
331
|
};
|
|
332
332
|
}
|
|
333
|
-
function codeProblem(code,
|
|
334
|
-
const out = { severity: "error", code, message, uri };
|
|
333
|
+
function codeProblem(code, message2, uri) {
|
|
334
|
+
const out = { severity: "error", code, message: message2, uri };
|
|
335
335
|
const topic = helpTopicForCode(code);
|
|
336
336
|
if (topic) out.help_topic = topic;
|
|
337
337
|
return out;
|
|
@@ -1989,6 +1989,239 @@ function developSurface(host, opts = {}) {
|
|
|
1989
1989
|
};
|
|
1990
1990
|
}
|
|
1991
1991
|
|
|
1992
|
+
// src/connections.ts
|
|
1993
|
+
import fs from "node:fs";
|
|
1994
|
+
import path2 from "node:path";
|
|
1995
|
+
import url2 from "node:url";
|
|
1996
|
+
import { createRequire } from "node:module";
|
|
1997
|
+
import { getRegisteredConnectionTypes } from "@malloydata/malloy";
|
|
1998
|
+
var CONNECTIONS_PKG = "@malloydata/malloy-connections";
|
|
1999
|
+
var CONNECTOR_IMPORTS = [
|
|
2000
|
+
"@malloydata/db-bigquery",
|
|
2001
|
+
"@malloydata/db-duckdb/native",
|
|
2002
|
+
"@malloydata/db-mysql",
|
|
2003
|
+
"@malloydata/db-databricks",
|
|
2004
|
+
"@malloydata/db-postgres",
|
|
2005
|
+
"@malloydata/db-snowflake",
|
|
2006
|
+
"@malloydata/db-trino"
|
|
2007
|
+
];
|
|
2008
|
+
var REGISTRY_WRITERS = [
|
|
2009
|
+
CONNECTIONS_PKG,
|
|
2010
|
+
"@malloydata/db-bigquery",
|
|
2011
|
+
"@malloydata/db-databricks",
|
|
2012
|
+
"@malloydata/db-duckdb",
|
|
2013
|
+
"@malloydata/db-mysql",
|
|
2014
|
+
"@malloydata/db-postgres",
|
|
2015
|
+
"@malloydata/db-snowflake",
|
|
2016
|
+
"@malloydata/db-trino"
|
|
2017
|
+
];
|
|
2018
|
+
function message(e) {
|
|
2019
|
+
return e instanceof Error ? e.message : String(e);
|
|
2020
|
+
}
|
|
2021
|
+
function oneLine(text) {
|
|
2022
|
+
return text.split("\n")[0];
|
|
2023
|
+
}
|
|
2024
|
+
function indent(text, by = " ") {
|
|
2025
|
+
return text.split("\n").map((l) => l ? by + l : l).join("\n");
|
|
2026
|
+
}
|
|
2027
|
+
function requireFrom(dir) {
|
|
2028
|
+
return createRequire(path2.join(dir, "__resolve__.cjs"));
|
|
2029
|
+
}
|
|
2030
|
+
function packageRootOf(entry) {
|
|
2031
|
+
let dir = path2.dirname(entry);
|
|
2032
|
+
for (; ; ) {
|
|
2033
|
+
if (fs.existsSync(path2.join(dir, "package.json"))) return dir;
|
|
2034
|
+
const up = path2.dirname(dir);
|
|
2035
|
+
if (up === dir) return null;
|
|
2036
|
+
dir = up;
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
function packageDir(spec, from) {
|
|
2040
|
+
const req = requireFrom(from);
|
|
2041
|
+
try {
|
|
2042
|
+
return fs.realpathSync(path2.dirname(req.resolve(`${spec}/package.json`)));
|
|
2043
|
+
} catch {
|
|
2044
|
+
}
|
|
2045
|
+
try {
|
|
2046
|
+
const root = packageRootOf(req.resolve(spec));
|
|
2047
|
+
return root ? fs.realpathSync(root) : null;
|
|
2048
|
+
} catch {
|
|
2049
|
+
return null;
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
function readVersion(pkgDir) {
|
|
2053
|
+
try {
|
|
2054
|
+
const raw = fs.readFileSync(path2.join(pkgDir, "package.json"), "utf8");
|
|
2055
|
+
return String(JSON.parse(raw).version ?? "?");
|
|
2056
|
+
} catch {
|
|
2057
|
+
return "?";
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
function selfDir() {
|
|
2061
|
+
return path2.dirname(url2.fileURLToPath(import.meta.url));
|
|
2062
|
+
}
|
|
2063
|
+
function malloyCopies(from = selfDir()) {
|
|
2064
|
+
const here = from;
|
|
2065
|
+
const byDir = /* @__PURE__ */ new Map();
|
|
2066
|
+
const note = (from2, importer) => {
|
|
2067
|
+
if (!from2) return;
|
|
2068
|
+
const dir = packageDir("@malloydata/malloy", from2);
|
|
2069
|
+
if (!dir) return;
|
|
2070
|
+
const found = byDir.get(dir);
|
|
2071
|
+
if (found) {
|
|
2072
|
+
if (!found.importers.includes(importer)) found.importers.push(importer);
|
|
2073
|
+
return;
|
|
2074
|
+
}
|
|
2075
|
+
byDir.set(dir, { dir, version: readVersion(dir), importers: [importer] });
|
|
2076
|
+
};
|
|
2077
|
+
note(here, "malloyyo");
|
|
2078
|
+
const connectionsDir = packageDir(CONNECTIONS_PKG, here);
|
|
2079
|
+
for (const spec of REGISTRY_WRITERS) {
|
|
2080
|
+
note(packageDir(spec, connectionsDir ?? here) ?? packageDir(spec, here), spec);
|
|
2081
|
+
}
|
|
2082
|
+
return [...byDir.values()];
|
|
2083
|
+
}
|
|
2084
|
+
function duplicateMalloyReport(from) {
|
|
2085
|
+
const copies = malloyCopies(from);
|
|
2086
|
+
if (copies.length < 2) return null;
|
|
2087
|
+
const listing = copies.map((c) => `${c.version.padEnd(9)} ${c.dir}
|
|
2088
|
+
${" ".repeat(10)}\u21B3 ${c.importers.join(", ")}`).join("\n");
|
|
2089
|
+
return `${copies.length} copies of @malloydata/malloy are installed. The connection
|
|
2090
|
+
registry is module-level state, so each copy has its own: the connectors
|
|
2091
|
+
register with the copy THEY resolve, malloyyo reads the copy IT resolves,
|
|
2092
|
+
and when those differ every connection looks missing.
|
|
2093
|
+
|
|
2094
|
+
${indent(listing)}
|
|
2095
|
+
|
|
2096
|
+
This is about paths, not versions \u2014 two copies of the same version are still
|
|
2097
|
+
two registries. Collapse the tree to one copy:
|
|
2098
|
+
|
|
2099
|
+
npm ls @malloydata/malloy # show who pulls in the extra copy
|
|
2100
|
+
npm dedupe # often enough on its own
|
|
2101
|
+
|
|
2102
|
+
Or run malloyyo somewhere that has no @malloydata/* dependencies of its own
|
|
2103
|
+
(a global install, or \`npx @malloydata/malloyyo\` from an empty directory).`;
|
|
2104
|
+
}
|
|
2105
|
+
async function importConnector(spec, from) {
|
|
2106
|
+
let target = spec;
|
|
2107
|
+
try {
|
|
2108
|
+
target = url2.pathToFileURL(requireFrom(from).resolve(spec)).href;
|
|
2109
|
+
} catch {
|
|
2110
|
+
}
|
|
2111
|
+
await import(target);
|
|
2112
|
+
}
|
|
2113
|
+
var pending = null;
|
|
2114
|
+
function loadConnectionTypes() {
|
|
2115
|
+
return pending ??= register();
|
|
2116
|
+
}
|
|
2117
|
+
async function register() {
|
|
2118
|
+
const here = selfDir();
|
|
2119
|
+
const failures = [];
|
|
2120
|
+
try {
|
|
2121
|
+
await import(CONNECTIONS_PKG);
|
|
2122
|
+
} catch (bulk) {
|
|
2123
|
+
failures.push({ pkg: CONNECTIONS_PKG, error: message(bulk) });
|
|
2124
|
+
const from = packageDir(CONNECTIONS_PKG, here) ?? here;
|
|
2125
|
+
for (const spec of CONNECTOR_IMPORTS) {
|
|
2126
|
+
try {
|
|
2127
|
+
await importConnector(spec, from);
|
|
2128
|
+
} catch (e) {
|
|
2129
|
+
failures.push({ pkg: spec, error: message(e) });
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
const types = getRegisteredConnectionTypes();
|
|
2134
|
+
const duplicates = malloyCopies();
|
|
2135
|
+
const split = duplicates.length > 1 ? duplicates : null;
|
|
2136
|
+
if (types.length === 0) {
|
|
2137
|
+
throw new Error(noTypesRegistered(failures));
|
|
2138
|
+
}
|
|
2139
|
+
return { types, failures, duplicates: split };
|
|
2140
|
+
}
|
|
2141
|
+
function noTypesRegistered(failures) {
|
|
2142
|
+
const dup = duplicateMalloyReport();
|
|
2143
|
+
const head = `0 connection types registered after loading ${CONNECTIONS_PKG} \u2014 no database connection can be created.
|
|
2144
|
+
`;
|
|
2145
|
+
const why = dup ? `
|
|
2146
|
+
${dup}
|
|
2147
|
+
` : `
|
|
2148
|
+
Only one @malloydata/malloy was found from here, so the connector packages
|
|
2149
|
+
themselves did not load or did not register. Check the install:
|
|
2150
|
+
|
|
2151
|
+
npm ls @malloydata/malloy ${CONNECTIONS_PKG}
|
|
2152
|
+
`;
|
|
2153
|
+
return head + why + failureDetail(failures) + `
|
|
2154
|
+
Nothing is wrong with your malloy-config.json.`;
|
|
2155
|
+
}
|
|
2156
|
+
function failureDetail(failures) {
|
|
2157
|
+
if (failures.length === 0) return "";
|
|
2158
|
+
return `
|
|
2159
|
+
Connector packages that failed to load:
|
|
2160
|
+
` + failures.map((f) => indent(`${f.pkg}: ${f.error}`)).join("\n") + `
|
|
2161
|
+
`;
|
|
2162
|
+
}
|
|
2163
|
+
var warned2 = false;
|
|
2164
|
+
var warnedAboutDuplicates = false;
|
|
2165
|
+
function warnConnectionIssues(result) {
|
|
2166
|
+
if (warned2) return;
|
|
2167
|
+
warned2 = true;
|
|
2168
|
+
if (result.duplicates) {
|
|
2169
|
+
warnedAboutDuplicates = true;
|
|
2170
|
+
console.error(
|
|
2171
|
+
`warning: ${duplicateMalloyReport()}
|
|
2172
|
+
Registered so far: ${result.types.join(", ")}
|
|
2173
|
+
`
|
|
2174
|
+
);
|
|
2175
|
+
}
|
|
2176
|
+
const backends = result.failures.filter((f) => f.pkg !== CONNECTIONS_PKG);
|
|
2177
|
+
for (const f of backends) {
|
|
2178
|
+
console.error(`warning: connection backend ${f.pkg} unavailable \u2014 ${oneLine(f.error)}`);
|
|
2179
|
+
}
|
|
2180
|
+
const bulk = backends.length === 0 && result.failures.find((f) => f.pkg === CONNECTIONS_PKG);
|
|
2181
|
+
if (bulk) {
|
|
2182
|
+
console.error(
|
|
2183
|
+
`warning: ${CONNECTIONS_PKG} failed to load (${oneLine(bulk.error)}); loaded each backend directly instead \u2014 connections are available.`
|
|
2184
|
+
);
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
async function initConnections() {
|
|
2188
|
+
const result = await loadConnectionTypes();
|
|
2189
|
+
warnConnectionIssues(result);
|
|
2190
|
+
return result;
|
|
2191
|
+
}
|
|
2192
|
+
var MISSING_CONNECTION = /No connection named ".*" found in config/;
|
|
2193
|
+
function annotateConnectionError(text) {
|
|
2194
|
+
if (!MISSING_CONNECTION.test(text)) return text;
|
|
2195
|
+
const registered = getRegisteredConnectionTypes();
|
|
2196
|
+
const dup = duplicateMalloyReport();
|
|
2197
|
+
const parts = [text, ""];
|
|
2198
|
+
if (dup) {
|
|
2199
|
+
parts.push(
|
|
2200
|
+
warnedAboutDuplicates ? `This is very likely the split @malloydata/malloy install warned about above.
|
|
2201
|
+
` : `This is very likely the cause:
|
|
2202
|
+
|
|
2203
|
+
${indent(dup)}
|
|
2204
|
+
`
|
|
2205
|
+
);
|
|
2206
|
+
}
|
|
2207
|
+
parts.push(
|
|
2208
|
+
`Connection types registered: ${registered.length ? registered.join(", ") : "(none)"}`,
|
|
2209
|
+
`Connections come from malloy-config.json, e.g.`,
|
|
2210
|
+
indent(`{ "connections": { "duckdb": { "is": "duckdb" } } }`)
|
|
2211
|
+
);
|
|
2212
|
+
return parts.join("\n");
|
|
2213
|
+
}
|
|
2214
|
+
async function withConnectionDiagnostics(fn) {
|
|
2215
|
+
try {
|
|
2216
|
+
return await fn();
|
|
2217
|
+
} catch (e) {
|
|
2218
|
+
const text = message(e);
|
|
2219
|
+
const annotated = annotateConnectionError(text);
|
|
2220
|
+
if (annotated === text) throw e;
|
|
2221
|
+
throw new Error(annotated, { cause: e });
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
|
|
1992
2225
|
// src/host.ts
|
|
1993
2226
|
var ENTRY = "index.malloy";
|
|
1994
2227
|
var IDLE_SHUTDOWN_MS = 6e4;
|
|
@@ -2018,7 +2251,7 @@ function fsReader() {
|
|
|
2018
2251
|
if (u.protocol !== "file:") {
|
|
2019
2252
|
throw new Error(`unsupported URL scheme for import: ${u.href}`);
|
|
2020
2253
|
}
|
|
2021
|
-
return
|
|
2254
|
+
return fs2.promises.readFile(u, "utf8");
|
|
2022
2255
|
}
|
|
2023
2256
|
};
|
|
2024
2257
|
}
|
|
@@ -2029,9 +2262,9 @@ async function loadConfig(rootUrl, reader) {
|
|
|
2029
2262
|
});
|
|
2030
2263
|
}
|
|
2031
2264
|
async function makeRunner(root) {
|
|
2032
|
-
await
|
|
2033
|
-
const abs =
|
|
2034
|
-
const rootUrl =
|
|
2265
|
+
await initConnections();
|
|
2266
|
+
const abs = path3.resolve(root);
|
|
2267
|
+
const rootUrl = url3.pathToFileURL(abs + path3.sep);
|
|
2035
2268
|
const reader = fsReader();
|
|
2036
2269
|
let configPromise = null;
|
|
2037
2270
|
const getConfig = () => configPromise ??= loadConfig(rootUrl, reader);
|
|
@@ -2054,12 +2287,12 @@ async function makeRunner(root) {
|
|
|
2054
2287
|
};
|
|
2055
2288
|
async function leaseIn(entryFile, fn) {
|
|
2056
2289
|
const config = await getConfig();
|
|
2057
|
-
const { reader: prepared, entry } = prepareSource(reader, { url:
|
|
2290
|
+
const { reader: prepared, entry } = prepareSource(reader, { url: path3.join(abs, entryFile) });
|
|
2058
2291
|
const runtime = new Runtime({ config, urlReader: prepared });
|
|
2059
2292
|
inFlight++;
|
|
2060
2293
|
clearIdleTimer();
|
|
2061
2294
|
try {
|
|
2062
|
-
return await fn(runtime, entry);
|
|
2295
|
+
return await withConnectionDiagnostics(() => fn(runtime, entry));
|
|
2063
2296
|
} finally {
|
|
2064
2297
|
inFlight--;
|
|
2065
2298
|
if (inFlight === 0) scheduleIdleShutdown();
|
|
@@ -2068,7 +2301,7 @@ async function makeRunner(root) {
|
|
|
2068
2301
|
const lease = (fn) => leaseIn(ENTRY, fn);
|
|
2069
2302
|
return {
|
|
2070
2303
|
root: abs,
|
|
2071
|
-
entryExists: () =>
|
|
2304
|
+
entryExists: () => fs2.existsSync(path3.join(abs, ENTRY)),
|
|
2072
2305
|
async dispose() {
|
|
2073
2306
|
clearIdleTimer();
|
|
2074
2307
|
if (!configPromise) return;
|
|
@@ -2397,24 +2630,24 @@ function readAll() {
|
|
|
2397
2630
|
return {};
|
|
2398
2631
|
}
|
|
2399
2632
|
}
|
|
2400
|
-
function loadCreds(
|
|
2401
|
-
return readAll()[
|
|
2633
|
+
function loadCreds(url6) {
|
|
2634
|
+
return readAll()[url6];
|
|
2402
2635
|
}
|
|
2403
|
-
function saveCreds(
|
|
2636
|
+
function saveCreds(url6, creds) {
|
|
2404
2637
|
const p = credsPath();
|
|
2405
2638
|
mkdirSync(dirname(p), { recursive: true });
|
|
2406
2639
|
const all = readAll();
|
|
2407
|
-
all[
|
|
2640
|
+
all[url6] = creds;
|
|
2408
2641
|
writeFileSync(p, JSON.stringify(all, null, 2) + "\n", { mode: 384 });
|
|
2409
2642
|
try {
|
|
2410
2643
|
chmodSync(p, 384);
|
|
2411
2644
|
} catch {
|
|
2412
2645
|
}
|
|
2413
2646
|
}
|
|
2414
|
-
function clearCreds(
|
|
2647
|
+
function clearCreds(url6) {
|
|
2415
2648
|
const all = readAll();
|
|
2416
|
-
if (!(
|
|
2417
|
-
delete all[
|
|
2649
|
+
if (!(url6 in all)) return false;
|
|
2650
|
+
delete all[url6];
|
|
2418
2651
|
writeFileSync(credsPath(), JSON.stringify(all, null, 2) + "\n", { mode: 384 });
|
|
2419
2652
|
return true;
|
|
2420
2653
|
}
|
|
@@ -2447,8 +2680,8 @@ async function registerClient(registrationEndpoint, redirectUri) {
|
|
|
2447
2680
|
if (!res.ok) throw new Error(`client registration failed: ${res.status} ${await res.text()}`);
|
|
2448
2681
|
return (await res.json()).client_id;
|
|
2449
2682
|
}
|
|
2450
|
-
function openBrowser(
|
|
2451
|
-
const [cmd, args] = process.platform === "darwin" ? ["open", [
|
|
2683
|
+
function openBrowser(url6) {
|
|
2684
|
+
const [cmd, args] = process.platform === "darwin" ? ["open", [url6]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url6]] : ["xdg-open", [url6]];
|
|
2452
2685
|
try {
|
|
2453
2686
|
spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
|
|
2454
2687
|
} catch {
|
|
@@ -2577,9 +2810,9 @@ Run: malloyyo login ${target.name}`);
|
|
|
2577
2810
|
}
|
|
2578
2811
|
|
|
2579
2812
|
// src/mcp.ts
|
|
2580
|
-
import
|
|
2581
|
-
import
|
|
2582
|
-
import
|
|
2813
|
+
import fs3 from "node:fs";
|
|
2814
|
+
import path4 from "node:path";
|
|
2815
|
+
import url4 from "node:url";
|
|
2583
2816
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2584
2817
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
2585
2818
|
import {
|
|
@@ -2667,7 +2900,7 @@ function defaultConfig(rootUrl) {
|
|
|
2667
2900
|
});
|
|
2668
2901
|
}
|
|
2669
2902
|
async function loadConfig2(root, reader) {
|
|
2670
|
-
const rootUrl =
|
|
2903
|
+
const rootUrl = url4.pathToFileURL(root + path4.sep);
|
|
2671
2904
|
let discovered;
|
|
2672
2905
|
try {
|
|
2673
2906
|
discovered = await discoverConfig2(rootUrl, rootUrl, reader);
|
|
@@ -2689,13 +2922,13 @@ function fsReader2() {
|
|
|
2689
2922
|
if (u.protocol !== "file:") {
|
|
2690
2923
|
throw new Error(`unsupported URL scheme for import: ${u.href}`);
|
|
2691
2924
|
}
|
|
2692
|
-
return
|
|
2925
|
+
return fs3.promises.readFile(u, "utf8");
|
|
2693
2926
|
}
|
|
2694
2927
|
};
|
|
2695
2928
|
}
|
|
2696
2929
|
function resolveUnderRoot(root, p) {
|
|
2697
|
-
const abs = p.includes("://") ?
|
|
2698
|
-
if (abs !== root && !abs.startsWith(root +
|
|
2930
|
+
const abs = p.includes("://") ? path4.resolve(decodeURIComponent(new URL(p).pathname)) : path4.resolve(root, p);
|
|
2931
|
+
if (abs !== root && !abs.startsWith(root + path4.sep)) {
|
|
2699
2932
|
throw new Error(`path is outside the project root: ${p}`);
|
|
2700
2933
|
}
|
|
2701
2934
|
return abs;
|
|
@@ -2704,7 +2937,7 @@ function makeConfigSource(root) {
|
|
|
2704
2937
|
let cached;
|
|
2705
2938
|
const signature = () => ["malloy-config.json", "malloy-config-local.json"].map((name) => {
|
|
2706
2939
|
try {
|
|
2707
|
-
const st =
|
|
2940
|
+
const st = fs3.statSync(path4.join(root, name));
|
|
2708
2941
|
return `${name}:${st.mtimeMs}:${st.size}`;
|
|
2709
2942
|
} catch {
|
|
2710
2943
|
return `${name}:absent`;
|
|
@@ -2724,12 +2957,12 @@ function makeWithRuntime(root, currentConfig) {
|
|
|
2724
2957
|
return gateConfigProblems(problems, async () => {
|
|
2725
2958
|
const resolved = "url" in input ? { url: resolveUnderRoot(root, input.url) } : {
|
|
2726
2959
|
source: input.source,
|
|
2727
|
-
baseUrl: input.baseUrl ? resolveUnderRoot(root, input.baseUrl) : root +
|
|
2960
|
+
baseUrl: input.baseUrl ? resolveUnderRoot(root, input.baseUrl) : root + path4.sep
|
|
2728
2961
|
};
|
|
2729
2962
|
const { reader, entry, readSource } = prepareSource(fsReader2(), resolved);
|
|
2730
2963
|
const runtime = new Runtime2({ config, urlReader: reader });
|
|
2731
2964
|
try {
|
|
2732
|
-
return await fn({ runtime, entry, readSource });
|
|
2965
|
+
return await withConnectionDiagnostics(() => fn({ runtime, entry, readSource }));
|
|
2733
2966
|
} finally {
|
|
2734
2967
|
await config.shutdown("idle");
|
|
2735
2968
|
}
|
|
@@ -2738,7 +2971,7 @@ function makeWithRuntime(root, currentConfig) {
|
|
|
2738
2971
|
}
|
|
2739
2972
|
function makeExploreHost(root, currentConfig) {
|
|
2740
2973
|
const withRuntime = makeWithRuntime(root, currentConfig);
|
|
2741
|
-
const published = (ref) => ref === ENTRY2 &&
|
|
2974
|
+
const published = (ref) => ref === ENTRY2 && fs3.existsSync(path4.join(root, ENTRY2));
|
|
2742
2975
|
return {
|
|
2743
2976
|
withModel: (ref, fn) => {
|
|
2744
2977
|
if (!published(ref)) throw new Error(`no published model '${ref}'`);
|
|
@@ -2758,8 +2991,8 @@ function makeDevelopHost(root, currentConfig) {
|
|
|
2758
2991
|
return { withRuntime: makeWithRuntime(root, currentConfig) };
|
|
2759
2992
|
}
|
|
2760
2993
|
async function serveMcp(opts) {
|
|
2761
|
-
await
|
|
2762
|
-
const root =
|
|
2994
|
+
await initConnections();
|
|
2995
|
+
const root = path4.resolve(opts.root ?? process.cwd());
|
|
2763
2996
|
const mode = opts.mode ?? "explore";
|
|
2764
2997
|
const currentConfig = makeConfigSource(root);
|
|
2765
2998
|
const surface = mode === "develop" ? developSurface(makeDevelopHost(root, currentConfig)) : exploreSurface(makeExploreHost(root, currentConfig));
|
|
@@ -2783,8 +3016,8 @@ async function serveMcp(opts) {
|
|
|
2783
3016
|
|
|
2784
3017
|
// src/dashboard.ts
|
|
2785
3018
|
import http2 from "node:http";
|
|
2786
|
-
import
|
|
2787
|
-
import
|
|
3019
|
+
import fs5 from "node:fs";
|
|
3020
|
+
import path6 from "node:path";
|
|
2788
3021
|
import * as esbuild2 from "esbuild";
|
|
2789
3022
|
|
|
2790
3023
|
// src/shared/givens-url.ts
|
|
@@ -2829,11 +3062,11 @@ function navHtml(active, all, href, homeHref = "./") {
|
|
|
2829
3062
|
}
|
|
2830
3063
|
|
|
2831
3064
|
// src/discover.ts
|
|
2832
|
-
import
|
|
2833
|
-
import
|
|
3065
|
+
import fs4 from "node:fs";
|
|
3066
|
+
import path5 from "node:path";
|
|
2834
3067
|
import { fileURLToPath } from "node:url";
|
|
2835
|
-
import { createRequire } from "node:module";
|
|
2836
|
-
var require2 =
|
|
3068
|
+
import { createRequire as createRequire2 } from "node:module";
|
|
3069
|
+
var require2 = createRequire2(import.meta.url);
|
|
2837
3070
|
var HOST_LIBS = [
|
|
2838
3071
|
"react",
|
|
2839
3072
|
"react-dom",
|
|
@@ -2857,7 +3090,7 @@ function resolveRuntimeDir() {
|
|
|
2857
3090
|
new URL("../src/frame-runtime/", import.meta.url)
|
|
2858
3091
|
// built dist/ next to sibling src/ (checkout)
|
|
2859
3092
|
].map((u) => fileURLToPath(u));
|
|
2860
|
-
const found = candidates.find((c) =>
|
|
3093
|
+
const found = candidates.find((c) => fs4.existsSync(c));
|
|
2861
3094
|
if (!found) {
|
|
2862
3095
|
throw new Error(
|
|
2863
3096
|
"frame-runtime/ not found next to the CLI (looked in ./frame-runtime and ../src/frame-runtime). A published install should ship it in dist/; reinstall the CLI, or rebuild with `npm run build`."
|
|
@@ -2875,31 +3108,31 @@ var hostAliasPlugin = {
|
|
|
2875
3108
|
}
|
|
2876
3109
|
};
|
|
2877
3110
|
async function discoverDashboards(root, runner) {
|
|
2878
|
-
const dir =
|
|
2879
|
-
if (!
|
|
2880
|
-
const files =
|
|
3111
|
+
const dir = path5.join(root, "dashboards");
|
|
3112
|
+
if (!fs4.existsSync(dir)) return [];
|
|
3113
|
+
const files = fs4.readdirSync(dir).filter((f) => f.endsWith(".malloy")).sort();
|
|
2881
3114
|
const dashboards = [];
|
|
2882
3115
|
for (const file of files) {
|
|
2883
3116
|
const base = file.slice(0, -".malloy".length);
|
|
2884
|
-
const entryFile =
|
|
3117
|
+
const entryFile = path5.join("dashboards", file);
|
|
2885
3118
|
const res = await runner.artifactForFile(entryFile, base);
|
|
2886
3119
|
if (!res.ok) throw new Error(`dashboard ${file}: ${res.error}`);
|
|
2887
3120
|
if (!res.artifact) continue;
|
|
2888
|
-
const component = ["jsx", "tsx"].map((ext) =>
|
|
3121
|
+
const component = ["jsx", "tsx"].map((ext) => path5.join(dir, `${base}.${ext}`)).find((p) => fs4.existsSync(p));
|
|
2889
3122
|
dashboards.push({ ...res.artifact, name: res.artifact.name || base, entryFile, tsxPath: component });
|
|
2890
3123
|
}
|
|
2891
3124
|
return dashboards;
|
|
2892
3125
|
}
|
|
2893
3126
|
function browserBuildBase() {
|
|
2894
|
-
const shims =
|
|
3127
|
+
const shims = path5.join(resolveRuntimeDir(), "..", "shims");
|
|
2895
3128
|
return {
|
|
2896
3129
|
platform: "browser",
|
|
2897
3130
|
jsx: "automatic",
|
|
2898
3131
|
loader: { ".css": "empty" },
|
|
2899
3132
|
define: { "process.env.NODE_ENV": '"production"' },
|
|
2900
3133
|
alias: {
|
|
2901
|
-
assert:
|
|
2902
|
-
util:
|
|
3134
|
+
assert: path5.join(shims, "assert.cjs"),
|
|
3135
|
+
util: path5.join(shims, "util.cjs")
|
|
2903
3136
|
},
|
|
2904
3137
|
banner: {
|
|
2905
3138
|
js: "globalThis.process||={env:{},platform:'browser',versions:{},argv:[],cwd:()=>'/'};"
|
|
@@ -2908,17 +3141,17 @@ function browserBuildBase() {
|
|
|
2908
3141
|
}
|
|
2909
3142
|
|
|
2910
3143
|
// src/dashboard.ts
|
|
2911
|
-
var resolveFrameEntry = () =>
|
|
2912
|
-
var resolveInPageEntry = () =>
|
|
3144
|
+
var resolveFrameEntry = () => path6.join(resolveRuntimeDir(), "..", "frame-entry.tsx");
|
|
3145
|
+
var resolveInPageEntry = () => path6.join(resolveRuntimeDir(), "..", "frame-inpage-entry.tsx");
|
|
2913
3146
|
var esc2 = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
2914
3147
|
function makeBundler() {
|
|
2915
3148
|
const cache = /* @__PURE__ */ new Map();
|
|
2916
3149
|
const frameEntry = resolveFrameEntry();
|
|
2917
3150
|
const runtimeDir = resolveRuntimeDir();
|
|
2918
|
-
const runtimeIndex =
|
|
2919
|
-
const runtimeStamp = () =>
|
|
3151
|
+
const runtimeIndex = path6.join(runtimeDir, "index.ts");
|
|
3152
|
+
const runtimeStamp = () => fs5.statSync(frameEntry).mtimeMs + fs5.readdirSync(runtimeDir).map((f) => fs5.statSync(path6.join(runtimeDir, f)).mtimeMs).reduce((a, b) => a + b, 0);
|
|
2920
3153
|
return async function bundle(dash) {
|
|
2921
|
-
const stamp = runtimeStamp() + (dash.tsxPath ?
|
|
3154
|
+
const stamp = runtimeStamp() + (dash.tsxPath ? fs5.statSync(dash.tsxPath).mtimeMs : 0);
|
|
2922
3155
|
const hit = cache.get(dash.name);
|
|
2923
3156
|
if (hit && hit.stamp === stamp) return hit.js;
|
|
2924
3157
|
const result = await esbuild2.build({
|
|
@@ -2963,7 +3196,7 @@ function makeInPageBundler() {
|
|
|
2963
3196
|
let cached;
|
|
2964
3197
|
const entry = resolveInPageEntry();
|
|
2965
3198
|
const runtimeDir = resolveRuntimeDir();
|
|
2966
|
-
const stampOf = () =>
|
|
3199
|
+
const stampOf = () => fs5.statSync(entry).mtimeMs + fs5.readdirSync(runtimeDir).map((f) => fs5.statSync(path6.join(runtimeDir, f)).mtimeMs).reduce((a, b) => a + b, 0);
|
|
2967
3200
|
return async function bundle() {
|
|
2968
3201
|
const stamp = stampOf();
|
|
2969
3202
|
if (cached && cached.stamp === stamp) return cached.js;
|
|
@@ -3062,11 +3295,11 @@ window.addEventListener('message',async(e)=>{
|
|
|
3062
3295
|
dash.title
|
|
3063
3296
|
);
|
|
3064
3297
|
}
|
|
3065
|
-
function givensFromUrl(
|
|
3066
|
-
return givensFromSearch(
|
|
3298
|
+
function givensFromUrl(url6) {
|
|
3299
|
+
return givensFromSearch(url6.search);
|
|
3067
3300
|
}
|
|
3068
|
-
function urlStateFromUrl(
|
|
3069
|
-
return urlStateFromSearch(
|
|
3301
|
+
function urlStateFromUrl(url6) {
|
|
3302
|
+
return urlStateFromSearch(url6.search);
|
|
3070
3303
|
}
|
|
3071
3304
|
function frameDoc(dash, givenSpecs, initialGivens, initialUrlState, tileSpecs) {
|
|
3072
3305
|
const info = {
|
|
@@ -3093,8 +3326,8 @@ async function readBody(req) {
|
|
|
3093
3326
|
return Buffer.concat(chunks).toString("utf8");
|
|
3094
3327
|
}
|
|
3095
3328
|
async function serveDashboard(opts) {
|
|
3096
|
-
await
|
|
3097
|
-
const root =
|
|
3329
|
+
await initConnections();
|
|
3330
|
+
const root = path6.resolve(opts.root ?? process.cwd());
|
|
3098
3331
|
const port = opts.port ?? 4173;
|
|
3099
3332
|
const framePort = port + 1;
|
|
3100
3333
|
const frameBase = `http://localhost:${framePort}`;
|
|
@@ -3111,7 +3344,7 @@ async function serveDashboard(opts) {
|
|
|
3111
3344
|
let byName = new Map(dashboards.map((d) => [d.name, d]));
|
|
3112
3345
|
const bundle = makeBundler();
|
|
3113
3346
|
const inPageBundle = makeInPageBundler();
|
|
3114
|
-
const pick = (
|
|
3347
|
+
const pick = (url6) => byName.get(url6.searchParams.get("d") ?? dashboards[0].name) ?? dashboards[0];
|
|
3115
3348
|
async function resolveGivens(dash) {
|
|
3116
3349
|
if (dash.tiles && dash.entryFile) {
|
|
3117
3350
|
const t = await runner.dashboardTiles(dash.entryFile, dash.tiles);
|
|
@@ -3127,7 +3360,7 @@ async function serveDashboard(opts) {
|
|
|
3127
3360
|
};
|
|
3128
3361
|
let debounce;
|
|
3129
3362
|
try {
|
|
3130
|
-
|
|
3363
|
+
fs5.watch(root, { recursive: true }, (_evt, filename) => {
|
|
3131
3364
|
const f = filename?.toString() ?? "";
|
|
3132
3365
|
if (!f.endsWith(".malloy") && !f.includes("dashboards")) return;
|
|
3133
3366
|
clearTimeout(debounce);
|
|
@@ -3145,15 +3378,15 @@ async function serveDashboard(opts) {
|
|
|
3145
3378
|
}
|
|
3146
3379
|
const handler = async (req, res) => {
|
|
3147
3380
|
const onFramePort = (req.socket.localPort ?? port) === framePort;
|
|
3148
|
-
const
|
|
3381
|
+
const url6 = new URL(req.url ?? "/", `http://localhost:${onFramePort ? framePort : port}`);
|
|
3149
3382
|
const send = (code, type, body, extra = {}) => {
|
|
3150
3383
|
res.writeHead(code, { "content-type": type, ...extra });
|
|
3151
3384
|
res.end(body);
|
|
3152
3385
|
};
|
|
3153
3386
|
try {
|
|
3154
3387
|
if (onFramePort) {
|
|
3155
|
-
if (
|
|
3156
|
-
const dash = pick(
|
|
3388
|
+
if (url6.pathname === "/frame") {
|
|
3389
|
+
const dash = pick(url6);
|
|
3157
3390
|
const g = await resolveGivens(dash);
|
|
3158
3391
|
if (!g.ok) {
|
|
3159
3392
|
return send(
|
|
@@ -3165,23 +3398,23 @@ async function serveDashboard(opts) {
|
|
|
3165
3398
|
return send(
|
|
3166
3399
|
200,
|
|
3167
3400
|
"text/html; charset=utf-8",
|
|
3168
|
-
frameDoc(dash, g.union, givensFromUrl(
|
|
3401
|
+
frameDoc(dash, g.union, givensFromUrl(url6), urlStateFromUrl(url6), g.tiles)
|
|
3169
3402
|
);
|
|
3170
3403
|
}
|
|
3171
|
-
if (
|
|
3172
|
-
return send(200, "application/javascript; charset=utf-8", await bundle(pick(
|
|
3404
|
+
if (url6.pathname === "/bundle.js") {
|
|
3405
|
+
return send(200, "application/javascript; charset=utf-8", await bundle(pick(url6)));
|
|
3173
3406
|
}
|
|
3174
3407
|
return send(404, "text/plain", "not found");
|
|
3175
3408
|
}
|
|
3176
|
-
if (
|
|
3409
|
+
if (url6.pathname === "/events") {
|
|
3177
3410
|
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
|
|
3178
3411
|
res.write("retry: 1000\n\n");
|
|
3179
3412
|
sseClients.add(res);
|
|
3180
3413
|
req.on("close", () => sseClients.delete(res));
|
|
3181
3414
|
return;
|
|
3182
3415
|
}
|
|
3183
|
-
if (
|
|
3184
|
-
const dash = pick(
|
|
3416
|
+
if (url6.pathname === "/") {
|
|
3417
|
+
const dash = pick(url6);
|
|
3185
3418
|
if (!dash.tsxPath) {
|
|
3186
3419
|
const g = await resolveGivens(dash);
|
|
3187
3420
|
if (!g.ok) {
|
|
@@ -3194,19 +3427,19 @@ async function serveDashboard(opts) {
|
|
|
3194
3427
|
return send(
|
|
3195
3428
|
200,
|
|
3196
3429
|
"text/html; charset=utf-8",
|
|
3197
|
-
inPageShell(dash, dashboards, g.union, givensFromUrl(
|
|
3430
|
+
inPageShell(dash, dashboards, g.union, givensFromUrl(url6), urlStateFromUrl(url6), g.tiles)
|
|
3198
3431
|
);
|
|
3199
3432
|
}
|
|
3200
3433
|
return send(
|
|
3201
3434
|
200,
|
|
3202
3435
|
"text/html; charset=utf-8",
|
|
3203
|
-
parentShell(dash, frameBase, dashboards, givensFromUrl(
|
|
3436
|
+
parentShell(dash, frameBase, dashboards, givensFromUrl(url6), urlStateFromUrl(url6))
|
|
3204
3437
|
);
|
|
3205
3438
|
}
|
|
3206
|
-
if (
|
|
3439
|
+
if (url6.pathname === "/inpage.js") {
|
|
3207
3440
|
return send(200, "application/javascript; charset=utf-8", await inPageBundle());
|
|
3208
3441
|
}
|
|
3209
|
-
if (
|
|
3442
|
+
if (url6.pathname === "/api/run" && req.method === "POST") {
|
|
3210
3443
|
const { d, query, malloy, givens } = JSON.parse(await readBody(req));
|
|
3211
3444
|
const dash = byName.get(d);
|
|
3212
3445
|
if (!dash) return send(404, "application/json", JSON.stringify({ ok: false, problems: [{ message: `no dashboard '${d}'` }] }));
|
|
@@ -3237,15 +3470,15 @@ async function serveDashboard(opts) {
|
|
|
3237
3470
|
}
|
|
3238
3471
|
|
|
3239
3472
|
// src/bundle.ts
|
|
3240
|
-
import
|
|
3241
|
-
import
|
|
3242
|
-
import { createRequire as
|
|
3473
|
+
import fs7 from "node:fs";
|
|
3474
|
+
import path8 from "node:path";
|
|
3475
|
+
import { createRequire as createRequire3 } from "node:module";
|
|
3243
3476
|
import * as esbuild3 from "esbuild";
|
|
3244
3477
|
|
|
3245
3478
|
// src/static-server.ts
|
|
3246
|
-
import
|
|
3479
|
+
import fs6 from "node:fs";
|
|
3247
3480
|
import http3 from "node:http";
|
|
3248
|
-
import
|
|
3481
|
+
import path7 from "node:path";
|
|
3249
3482
|
var MIME = {
|
|
3250
3483
|
".html": "text/html; charset=utf-8",
|
|
3251
3484
|
".js": "text/javascript; charset=utf-8",
|
|
@@ -3260,12 +3493,12 @@ var MIME = {
|
|
|
3260
3493
|
function serveStatic(dir, port) {
|
|
3261
3494
|
const server = http3.createServer((req, res) => {
|
|
3262
3495
|
const rel = decodeURIComponent((req.url ?? "/").split("?")[0]);
|
|
3263
|
-
let file =
|
|
3496
|
+
let file = path7.join(dir, rel === "/" ? "index.html" : rel);
|
|
3264
3497
|
if (!file.startsWith(dir)) return void res.writeHead(403).end();
|
|
3265
|
-
if (
|
|
3266
|
-
if (!
|
|
3267
|
-
const st =
|
|
3268
|
-
const type = MIME[
|
|
3498
|
+
if (fs6.existsSync(file) && fs6.statSync(file).isDirectory()) file = path7.join(file, "index.html");
|
|
3499
|
+
if (!fs6.existsSync(file)) return void res.writeHead(404).end("not found");
|
|
3500
|
+
const st = fs6.statSync(file);
|
|
3501
|
+
const type = MIME[path7.extname(file)] ?? "application/octet-stream";
|
|
3269
3502
|
const range = req.headers.range;
|
|
3270
3503
|
if (range) {
|
|
3271
3504
|
const m = /bytes=(\d*)-(\d*)/.exec(range);
|
|
@@ -3277,10 +3510,10 @@ function serveStatic(dir, port) {
|
|
|
3277
3510
|
"Accept-Ranges": "bytes",
|
|
3278
3511
|
"Content-Length": end - start + 1
|
|
3279
3512
|
});
|
|
3280
|
-
return void
|
|
3513
|
+
return void fs6.createReadStream(file, { start, end }).pipe(res);
|
|
3281
3514
|
}
|
|
3282
3515
|
res.writeHead(200, { "Content-Type": type, "Content-Length": st.size, "Accept-Ranges": "bytes" });
|
|
3283
|
-
|
|
3516
|
+
fs6.createReadStream(file).pipe(res);
|
|
3284
3517
|
});
|
|
3285
3518
|
return new Promise((resolve3, reject) => {
|
|
3286
3519
|
let attempt = 0;
|
|
@@ -3297,7 +3530,7 @@ function serveStatic(dir, port) {
|
|
|
3297
3530
|
if (p !== port) console.log(`
|
|
3298
3531
|
(port ${port} busy \u2014 using ${p})`);
|
|
3299
3532
|
console.log(`
|
|
3300
|
-
serving ${
|
|
3533
|
+
serving ${path7.basename(dir)}/ on http://localhost:${p} (ctrl-c to stop)`);
|
|
3301
3534
|
resolve3();
|
|
3302
3535
|
});
|
|
3303
3536
|
};
|
|
@@ -3306,19 +3539,19 @@ serving ${path6.basename(dir)}/ on http://localhost:${p} (ctrl-c to stop)`);
|
|
|
3306
3539
|
}
|
|
3307
3540
|
|
|
3308
3541
|
// src/bundle.ts
|
|
3309
|
-
var require3 =
|
|
3542
|
+
var require3 = createRequire3(import.meta.url);
|
|
3310
3543
|
var esc3 = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
3311
3544
|
function inlineModelFiles(root) {
|
|
3312
3545
|
const files = {};
|
|
3313
3546
|
const skip = /* @__PURE__ */ new Set(["node_modules", ".git", "docs", "dist"]);
|
|
3314
3547
|
const walk = (dir) => {
|
|
3315
|
-
for (const entry of
|
|
3548
|
+
for (const entry of fs7.readdirSync(dir, { withFileTypes: true })) {
|
|
3316
3549
|
if (entry.name.startsWith(".") || skip.has(entry.name)) continue;
|
|
3317
|
-
const abs =
|
|
3550
|
+
const abs = path8.join(dir, entry.name);
|
|
3318
3551
|
if (entry.isDirectory()) walk(abs);
|
|
3319
3552
|
else if (entry.name.endsWith(".malloy")) {
|
|
3320
|
-
const rel =
|
|
3321
|
-
files[`file:///${rel}`] =
|
|
3553
|
+
const rel = path8.relative(root, abs).split(path8.sep).join("/");
|
|
3554
|
+
files[`file:///${rel}`] = fs7.readFileSync(abs, "utf8");
|
|
3322
3555
|
}
|
|
3323
3556
|
}
|
|
3324
3557
|
};
|
|
@@ -3381,12 +3614,12 @@ function copyDuckDBAssets(outDir) {
|
|
|
3381
3614
|
"duckdb-browser-mvp.worker.js",
|
|
3382
3615
|
"duckdb-browser-eh.worker.js"
|
|
3383
3616
|
];
|
|
3384
|
-
const dir =
|
|
3385
|
-
|
|
3617
|
+
const dir = path8.join(outDir, "duckdb");
|
|
3618
|
+
fs7.mkdirSync(dir, { recursive: true });
|
|
3386
3619
|
const copied = [];
|
|
3387
3620
|
for (const n of names) {
|
|
3388
3621
|
const src = require3.resolve(`@duckdb/duckdb-wasm/dist/${n}`);
|
|
3389
|
-
|
|
3622
|
+
fs7.copyFileSync(src, path8.join(dir, n));
|
|
3390
3623
|
copied.push(n);
|
|
3391
3624
|
}
|
|
3392
3625
|
return copied;
|
|
@@ -3485,37 +3718,37 @@ body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 -apple-system,B
|
|
|
3485
3718
|
.index span{font-size:13px;color:var(--muted)}
|
|
3486
3719
|
` + NAV_CSS;
|
|
3487
3720
|
async function bundleDashboards(opts = {}) {
|
|
3488
|
-
const root =
|
|
3489
|
-
const outDir =
|
|
3490
|
-
const title = opts.title ??
|
|
3721
|
+
const root = path8.resolve(opts.root ?? process.cwd());
|
|
3722
|
+
const outDir = path8.resolve(root, opts.out ?? "docs");
|
|
3723
|
+
const title = opts.title ?? path8.basename(root);
|
|
3491
3724
|
const target = opts.target ?? "pages";
|
|
3492
3725
|
const analytics = opts.analytics ?? readSiteConfig(root).analytics;
|
|
3493
3726
|
const cleanUrls = target === "vercel";
|
|
3494
3727
|
const selfHostDuckdb = opts.duckdb === "bundled";
|
|
3495
3728
|
const runner = await makeRunner(root);
|
|
3496
3729
|
const dashboards = await discoverDashboards(root, runner);
|
|
3497
|
-
if (dashboards.length === 0) throw new Error(`no dashboards found in ${
|
|
3498
|
-
const manifestPath =
|
|
3730
|
+
if (dashboards.length === 0) throw new Error(`no dashboards found in ${path8.join(root, "dashboards")}`);
|
|
3731
|
+
const manifestPath = path8.join(outDir, ".bundle-manifest.json");
|
|
3499
3732
|
let priorData = [];
|
|
3500
3733
|
try {
|
|
3501
|
-
priorData = JSON.parse(
|
|
3734
|
+
priorData = JSON.parse(fs7.readFileSync(manifestPath, "utf8")).dataFiles ?? [];
|
|
3502
3735
|
} catch {
|
|
3503
3736
|
}
|
|
3504
3737
|
for (const sub of ["assets", "duckdb"]) {
|
|
3505
|
-
|
|
3738
|
+
fs7.rmSync(path8.join(outDir, sub), { recursive: true, force: true });
|
|
3506
3739
|
}
|
|
3507
|
-
if (
|
|
3508
|
-
for (const f of
|
|
3509
|
-
if (f.endsWith(".html"))
|
|
3740
|
+
if (fs7.existsSync(outDir)) {
|
|
3741
|
+
for (const f of fs7.readdirSync(outDir)) {
|
|
3742
|
+
if (f.endsWith(".html")) fs7.rmSync(path8.join(outDir, f), { force: true });
|
|
3510
3743
|
}
|
|
3511
3744
|
}
|
|
3512
|
-
|
|
3745
|
+
fs7.mkdirSync(path8.join(outDir, "assets"), { recursive: true });
|
|
3513
3746
|
if (target === "pages") {
|
|
3514
|
-
|
|
3747
|
+
fs7.writeFileSync(path8.join(outDir, ".nojekyll"), "");
|
|
3515
3748
|
} else {
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3749
|
+
fs7.rmSync(path8.join(outDir, ".nojekyll"), { force: true });
|
|
3750
|
+
fs7.writeFileSync(
|
|
3751
|
+
path8.join(outDir, "vercel.json"),
|
|
3519
3752
|
JSON.stringify(
|
|
3520
3753
|
{
|
|
3521
3754
|
$schema: "https://openapi.vercel.sh/vercel.json",
|
|
@@ -3537,53 +3770,53 @@ async function bundleDashboards(opts = {}) {
|
|
|
3537
3770
|
);
|
|
3538
3771
|
}
|
|
3539
3772
|
const modelFiles = inlineModelFiles(root);
|
|
3540
|
-
const outRel =
|
|
3773
|
+
const outRel = path8.relative(root, outDir).split(path8.sep).join("/");
|
|
3541
3774
|
const usedFiles = reachableModelFiles(
|
|
3542
3775
|
modelFiles,
|
|
3543
3776
|
dashboards.map((d) => d.entryFile).filter((f) => !!f)
|
|
3544
3777
|
);
|
|
3545
3778
|
const { map: tableFiles, copies } = tableFilePlan(usedFiles, outRel);
|
|
3546
3779
|
for (const rel of copies) {
|
|
3547
|
-
const from =
|
|
3548
|
-
if (!
|
|
3780
|
+
const from = path8.join(root, rel);
|
|
3781
|
+
if (!fs7.existsSync(from)) {
|
|
3549
3782
|
throw new Error(
|
|
3550
3783
|
`model reads '${rel}' but ${from} does not exist.
|
|
3551
3784
|
Data files are referenced by a path relative to the project root.`
|
|
3552
3785
|
);
|
|
3553
3786
|
}
|
|
3554
|
-
const to =
|
|
3555
|
-
|
|
3556
|
-
|
|
3787
|
+
const to = path8.join(outDir, rel);
|
|
3788
|
+
fs7.mkdirSync(path8.dirname(to), { recursive: true });
|
|
3789
|
+
fs7.copyFileSync(from, to);
|
|
3557
3790
|
}
|
|
3558
3791
|
const copiedData = copies;
|
|
3559
3792
|
for (const stale of priorData) {
|
|
3560
3793
|
if (copies.includes(stale)) continue;
|
|
3561
|
-
|
|
3794
|
+
fs7.rmSync(path8.join(outDir, stale), { force: true });
|
|
3562
3795
|
try {
|
|
3563
|
-
|
|
3796
|
+
fs7.rmdirSync(path8.dirname(path8.join(outDir, stale)));
|
|
3564
3797
|
} catch {
|
|
3565
3798
|
}
|
|
3566
3799
|
console.log(` removed stale ${stale}`);
|
|
3567
3800
|
}
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3801
|
+
fs7.writeFileSync(manifestPath, JSON.stringify({ dataFiles: copies }, null, 2) + "\n");
|
|
3802
|
+
fs7.writeFileSync(
|
|
3803
|
+
path8.join(outDir, "assets", "model-files.js"),
|
|
3571
3804
|
`window.__MODEL_FILES__ = ${JSON.stringify(modelFiles)};
|
|
3572
3805
|
window.__TABLE_FILES__ = ${JSON.stringify(tableFiles)};
|
|
3573
3806
|
` + (selfHostDuckdb ? `window.__DUCKDB_BASE__ = "./duckdb/";
|
|
3574
3807
|
` : "")
|
|
3575
3808
|
);
|
|
3576
|
-
const landing = ["jsx", "tsx"].map((ext) =>
|
|
3809
|
+
const landing = ["jsx", "tsx"].map((ext) => path8.join(root, "dashboards", `index.${ext}`)).find((f) => fs7.existsSync(f));
|
|
3577
3810
|
const runtimeDir = resolveRuntimeDir();
|
|
3578
|
-
const runtimeIndex =
|
|
3579
|
-
const wasmEntry =
|
|
3811
|
+
const runtimeIndex = path8.join(runtimeDir, "index.ts");
|
|
3812
|
+
const wasmEntry = path8.join(runtimeDir, "..", "frame-wasm-entry.tsx");
|
|
3580
3813
|
const byEntry = new Map(dashboards.map((d) => [`vdash:${d.name}`, d]));
|
|
3581
3814
|
await esbuild3.build({
|
|
3582
3815
|
entryPoints: Object.fromEntries(dashboards.map((d) => [d.name, `vdash:${d.name}`])),
|
|
3583
3816
|
bundle: true,
|
|
3584
3817
|
splitting: true,
|
|
3585
3818
|
format: "esm",
|
|
3586
|
-
outdir:
|
|
3819
|
+
outdir: path8.join(outDir, "assets"),
|
|
3587
3820
|
minify: true,
|
|
3588
3821
|
logLevel: "warning",
|
|
3589
3822
|
...browserBuildBase(),
|
|
@@ -3604,7 +3837,7 @@ boot(Dashboard);
|
|
|
3604
3837
|
loader: "js",
|
|
3605
3838
|
// Resolve the component's own imports (react, @malloyyo/dashboard)
|
|
3606
3839
|
// from the model repo's directory, matching `dashboard dev`.
|
|
3607
|
-
resolveDir:
|
|
3840
|
+
resolveDir: path8.dirname(dash.tsxPath ?? path8.join(root, "dashboards", "x"))
|
|
3608
3841
|
};
|
|
3609
3842
|
});
|
|
3610
3843
|
b.onResolve({ filter: /^@malloyyo\/dashboard$/ }, () => ({ path: runtimeIndex }));
|
|
@@ -3613,7 +3846,7 @@ boot(Dashboard);
|
|
|
3613
3846
|
hostAliasPlugin
|
|
3614
3847
|
]
|
|
3615
3848
|
});
|
|
3616
|
-
|
|
3849
|
+
fs7.writeFileSync(path8.join(outDir, "assets", "site.css"), SITE_CSS);
|
|
3617
3850
|
for (const d of dashboards) {
|
|
3618
3851
|
let specs = [];
|
|
3619
3852
|
let tileSpecs;
|
|
@@ -3626,9 +3859,9 @@ boot(Dashboard);
|
|
|
3626
3859
|
if (!got.ok) throw new Error(`dashboard ${d.name}: ${got.error}`);
|
|
3627
3860
|
specs = got.givens;
|
|
3628
3861
|
}
|
|
3629
|
-
|
|
3862
|
+
fs7.writeFileSync(path8.join(outDir, `${d.name}.html`), page(d, dashboards, title, specs, tileSpecs, cleanUrls, analytics));
|
|
3630
3863
|
}
|
|
3631
|
-
|
|
3864
|
+
fs7.writeFileSync(path8.join(outDir, "index.html"), indexPage(dashboards, title, !!landing, cleanUrls, analytics));
|
|
3632
3865
|
if (landing) {
|
|
3633
3866
|
await esbuild3.build({
|
|
3634
3867
|
stdin: {
|
|
@@ -3637,13 +3870,13 @@ import { createRoot } from "react-dom/client";
|
|
|
3637
3870
|
import Landing from ${JSON.stringify(landing)};
|
|
3638
3871
|
createRoot(document.getElementById("root")).render(React.createElement(Landing, { dashboards: window.__DASHBOARDS__ || [] }));
|
|
3639
3872
|
`,
|
|
3640
|
-
resolveDir:
|
|
3873
|
+
resolveDir: path8.dirname(landing),
|
|
3641
3874
|
loader: "js"
|
|
3642
3875
|
},
|
|
3643
3876
|
bundle: true,
|
|
3644
3877
|
format: "esm",
|
|
3645
3878
|
minify: true,
|
|
3646
|
-
outfile:
|
|
3879
|
+
outfile: path8.join(outDir, "assets", "index.js"),
|
|
3647
3880
|
logLevel: "warning",
|
|
3648
3881
|
// Same base as the dashboard pass. A landing page needs no Malloy today,
|
|
3649
3882
|
// but one that imported anything reaching antlr4ts would otherwise die at
|
|
@@ -3653,12 +3886,12 @@ createRoot(document.getElementById("root")).render(React.createElement(Landing,
|
|
|
3653
3886
|
});
|
|
3654
3887
|
}
|
|
3655
3888
|
const duck = selfHostDuckdb ? copyDuckDBAssets(outDir) : [];
|
|
3656
|
-
if (!selfHostDuckdb)
|
|
3657
|
-
const bytes = (p) =>
|
|
3658
|
-
const assetDir =
|
|
3659
|
-
const jsTotal =
|
|
3889
|
+
if (!selfHostDuckdb) fs7.rmSync(path8.join(outDir, "duckdb"), { recursive: true, force: true });
|
|
3890
|
+
const bytes = (p) => fs7.statSync(p).size;
|
|
3891
|
+
const assetDir = path8.join(outDir, "assets");
|
|
3892
|
+
const jsTotal = fs7.readdirSync(assetDir).filter((f) => f.endsWith(".js")).reduce((a, f) => a + bytes(path8.join(assetDir, f)), 0);
|
|
3660
3893
|
console.log(`
|
|
3661
|
-
bundled ${dashboards.length} dashboard(s) \u2192 ${
|
|
3894
|
+
bundled ${dashboards.length} dashboard(s) \u2192 ${path8.relative(process.cwd(), outDir) || "."}/`);
|
|
3662
3895
|
for (const d of dashboards) console.log(` ${d.name}.html ${d.title ?? ""}`);
|
|
3663
3896
|
console.log(`
|
|
3664
3897
|
js ${(jsTotal / 1048576).toFixed(2)} MB`);
|
|
@@ -3668,13 +3901,13 @@ bundled ${dashboards.length} dashboard(s) \u2192 ${path7.relative(process.cwd(),
|
|
|
3668
3901
|
console.log(` model ${Object.keys(modelFiles).length} .malloy files inlined`);
|
|
3669
3902
|
if (analytics) console.log(` analytics ${analytics}`);
|
|
3670
3903
|
if (copiedData.length) {
|
|
3671
|
-
const mb = copiedData.reduce((a, r) => a +
|
|
3904
|
+
const mb = copiedData.reduce((a, r) => a + fs7.statSync(path8.join(root, r)).size, 0) / 1048576;
|
|
3672
3905
|
console.log(` data ${copiedData.length} file(s) copied, ${mb.toFixed(1)} MB (same-origin)`);
|
|
3673
3906
|
}
|
|
3674
3907
|
console.log(
|
|
3675
3908
|
target === "pages" ? `
|
|
3676
|
-
Publish: commit ${
|
|
3677
|
-
Publish: deploy ${
|
|
3909
|
+
Publish: commit ${path8.basename(outDir)}/ and point GitHub Pages at it.` : `
|
|
3910
|
+
Publish: deploy ${path8.basename(outDir)}/ to Vercel (vercel.json written: clean URLs + asset caching).`
|
|
3678
3911
|
);
|
|
3679
3912
|
if (opts.serve !== false) {
|
|
3680
3913
|
await serveStatic(outDir, opts.port ?? 4180);
|
|
@@ -3684,8 +3917,8 @@ Publish: deploy ${path7.basename(outDir)}/ to Vercel (vercel.json written: clean
|
|
|
3684
3917
|
}
|
|
3685
3918
|
|
|
3686
3919
|
// src/init.ts
|
|
3687
|
-
import
|
|
3688
|
-
import
|
|
3920
|
+
import fs8 from "node:fs";
|
|
3921
|
+
import path9 from "node:path";
|
|
3689
3922
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
3690
3923
|
var AUTHOR_SERVER = "malloyyo_author";
|
|
3691
3924
|
var AUTHOR_MCP = {
|
|
@@ -3720,11 +3953,11 @@ function withAuthorPermissions(input) {
|
|
|
3720
3953
|
return { settings, added };
|
|
3721
3954
|
}
|
|
3722
3955
|
function allowAuthorTools(root) {
|
|
3723
|
-
const file =
|
|
3956
|
+
const file = path9.join(root, ".claude", "settings.json");
|
|
3724
3957
|
let existing;
|
|
3725
|
-
if (
|
|
3958
|
+
if (fs8.existsSync(file)) {
|
|
3726
3959
|
try {
|
|
3727
|
-
existing = JSON.parse(
|
|
3960
|
+
existing = JSON.parse(fs8.readFileSync(file, "utf8"));
|
|
3728
3961
|
} catch {
|
|
3729
3962
|
return { added: [], note: ".claude/settings.json isn't valid JSON \u2014 left as-is" };
|
|
3730
3963
|
}
|
|
@@ -3732,8 +3965,8 @@ function allowAuthorTools(root) {
|
|
|
3732
3965
|
const merged = withAuthorPermissions(existing);
|
|
3733
3966
|
if ("error" in merged) return { added: [], note: `${merged.error} \u2014 left as-is` };
|
|
3734
3967
|
if (merged.added.length === 0) return { added: [] };
|
|
3735
|
-
|
|
3736
|
-
|
|
3968
|
+
fs8.mkdirSync(path9.dirname(file), { recursive: true });
|
|
3969
|
+
fs8.writeFileSync(file, JSON.stringify(merged.settings, null, 2) + "\n");
|
|
3737
3970
|
return { added: merged.added };
|
|
3738
3971
|
}
|
|
3739
3972
|
function exportableNames(src) {
|
|
@@ -3748,11 +3981,11 @@ function exportableNames(src) {
|
|
|
3748
3981
|
return [...names];
|
|
3749
3982
|
}
|
|
3750
3983
|
function scaffoldIndex(root) {
|
|
3751
|
-
const indexPath =
|
|
3752
|
-
if (
|
|
3984
|
+
const indexPath = path9.join(root, "index.malloy");
|
|
3985
|
+
if (fs8.existsSync(indexPath)) {
|
|
3753
3986
|
return { wrote: false, note: "index.malloy already exists \u2014 left as-is" };
|
|
3754
3987
|
}
|
|
3755
|
-
const models =
|
|
3988
|
+
const models = fs8.readdirSync(root).filter((f) => f.endsWith(".malloy") && f !== "index.malloy").sort();
|
|
3756
3989
|
if (models.length === 0) {
|
|
3757
3990
|
return { wrote: false, note: "no .malloy files found \u2014 skipped index.malloy" };
|
|
3758
3991
|
}
|
|
@@ -3765,7 +3998,7 @@ function scaffoldIndex(root) {
|
|
|
3765
3998
|
];
|
|
3766
3999
|
let anyNames = false;
|
|
3767
4000
|
for (const file of models) {
|
|
3768
|
-
const names = exportableNames(
|
|
4001
|
+
const names = exportableNames(fs8.readFileSync(path9.join(root, file), "utf8"));
|
|
3769
4002
|
if (names.length === 0) {
|
|
3770
4003
|
blocks.push(`// ${file}: no top-level source/query/given detected \u2014 add exports by hand`);
|
|
3771
4004
|
continue;
|
|
@@ -3776,49 +4009,49 @@ function scaffoldIndex(root) {
|
|
|
3776
4009
|
blocks.push(`export { ${list} }`);
|
|
3777
4010
|
blocks.push("");
|
|
3778
4011
|
}
|
|
3779
|
-
|
|
4012
|
+
fs8.writeFileSync(indexPath, blocks.join("\n") + "\n");
|
|
3780
4013
|
return {
|
|
3781
4014
|
wrote: true,
|
|
3782
4015
|
note: anyNames ? `wrote index.malloy re-exporting ${models.length} model file(s) \u2014 REVIEW it` : "wrote index.malloy skeleton \u2014 no exports detected, fill them in by hand"
|
|
3783
4016
|
};
|
|
3784
4017
|
}
|
|
3785
4018
|
function installSkills(root) {
|
|
3786
|
-
const distDir =
|
|
4019
|
+
const distDir = path9.dirname(fileURLToPath2(import.meta.url));
|
|
3787
4020
|
const candidates = [
|
|
3788
|
-
|
|
3789
|
-
|
|
4021
|
+
path9.join(distDir, "templates", "skills"),
|
|
4022
|
+
path9.join(distDir, "..", "src", "templates", "skills")
|
|
3790
4023
|
];
|
|
3791
|
-
const srcSkills = candidates.find((p) =>
|
|
4024
|
+
const srcSkills = candidates.find((p) => fs8.existsSync(p));
|
|
3792
4025
|
if (!srcSkills) return { wrote: [], skipped: [], note: "no skill templates found \u2014 skipped" };
|
|
3793
|
-
const destSkills =
|
|
3794
|
-
|
|
4026
|
+
const destSkills = path9.join(root, ".claude", "skills");
|
|
4027
|
+
fs8.mkdirSync(destSkills, { recursive: true });
|
|
3795
4028
|
const wrote = [];
|
|
3796
4029
|
const skipped = [];
|
|
3797
|
-
for (const name of
|
|
3798
|
-
const from =
|
|
3799
|
-
if (!
|
|
3800
|
-
const to =
|
|
3801
|
-
if (
|
|
4030
|
+
for (const name of fs8.readdirSync(srcSkills)) {
|
|
4031
|
+
const from = path9.join(srcSkills, name);
|
|
4032
|
+
if (!fs8.statSync(from).isDirectory()) continue;
|
|
4033
|
+
const to = path9.join(destSkills, name);
|
|
4034
|
+
if (fs8.existsSync(to)) {
|
|
3802
4035
|
skipped.push(name);
|
|
3803
4036
|
continue;
|
|
3804
4037
|
}
|
|
3805
|
-
|
|
4038
|
+
fs8.cpSync(from, to, { recursive: true });
|
|
3806
4039
|
wrote.push(name);
|
|
3807
4040
|
}
|
|
3808
4041
|
return { wrote, skipped };
|
|
3809
4042
|
}
|
|
3810
4043
|
async function initCmd(dir) {
|
|
3811
|
-
const root =
|
|
3812
|
-
if (!
|
|
4044
|
+
const root = path9.resolve(dir);
|
|
4045
|
+
if (!fs8.existsSync(root) || !fs8.statSync(root).isDirectory()) {
|
|
3813
4046
|
throw new Error(`not a directory: ${root}`);
|
|
3814
4047
|
}
|
|
3815
|
-
const mcpPath =
|
|
3816
|
-
if (
|
|
4048
|
+
const mcpPath = path9.join(root, ".mcp.json");
|
|
4049
|
+
if (fs8.existsSync(mcpPath)) {
|
|
3817
4050
|
console.log(`\u2022 .mcp.json exists \u2014 leaving it. For author-by-default it should be:`);
|
|
3818
4051
|
console.log(` ${JSON.stringify(AUTHOR_MCP.mcpServers.malloyyo_author)}`);
|
|
3819
4052
|
console.log(` (server key "malloyyo_author", command "malloyyo mcp --develop").`);
|
|
3820
4053
|
} else {
|
|
3821
|
-
|
|
4054
|
+
fs8.writeFileSync(mcpPath, JSON.stringify(AUTHOR_MCP, null, 2) + "\n");
|
|
3822
4055
|
console.log(`\u2713 wrote .mcp.json \u2014 \`cd ${dir} && claude\` now opens in AUTHOR mode`);
|
|
3823
4056
|
}
|
|
3824
4057
|
const idx = scaffoldIndex(root);
|
|
@@ -3852,9 +4085,9 @@ async function initCmd(dir) {
|
|
|
3852
4085
|
}
|
|
3853
4086
|
|
|
3854
4087
|
// src/sql.ts
|
|
3855
|
-
import
|
|
3856
|
-
import
|
|
3857
|
-
import
|
|
4088
|
+
import fs9 from "node:fs";
|
|
4089
|
+
import path10 from "node:path";
|
|
4090
|
+
import url5 from "node:url";
|
|
3858
4091
|
import { MalloyConfig as MalloyConfig3, Runtime as Runtime3, discoverConfig as discoverConfig3 } from "@malloydata/malloy";
|
|
3859
4092
|
function fileReader() {
|
|
3860
4093
|
return {
|
|
@@ -3862,12 +4095,12 @@ function fileReader() {
|
|
|
3862
4095
|
if (u.protocol !== "file:") {
|
|
3863
4096
|
throw new Error(`unsupported URL scheme for import: ${u.href}`);
|
|
3864
4097
|
}
|
|
3865
|
-
return
|
|
4098
|
+
return fs9.promises.readFile(u, "utf8");
|
|
3866
4099
|
}
|
|
3867
4100
|
};
|
|
3868
4101
|
}
|
|
3869
4102
|
async function loadConfig3(rootDir) {
|
|
3870
|
-
const rootUrl =
|
|
4103
|
+
const rootUrl = url5.pathToFileURL(rootDir.endsWith(path10.sep) ? rootDir : rootDir + path10.sep);
|
|
3871
4104
|
const discovered = await discoverConfig3(rootUrl, rootUrl, fileReader()).catch(() => null);
|
|
3872
4105
|
return discovered ?? new MalloyConfig3({ includeDefaultConnections: true }, {
|
|
3873
4106
|
rootDirectory: rootUrl.toString()
|
|
@@ -3898,22 +4131,22 @@ async function readStdin() {
|
|
|
3898
4131
|
}
|
|
3899
4132
|
async function resolveSql(opts) {
|
|
3900
4133
|
if (opts.execute != null) return opts.execute;
|
|
3901
|
-
if (opts.file) return
|
|
4134
|
+
if (opts.file) return fs9.promises.readFile(opts.file, "utf8");
|
|
3902
4135
|
return readStdin();
|
|
3903
4136
|
}
|
|
3904
4137
|
async function sqlCmd(connection, opts) {
|
|
3905
4138
|
const name = connection ?? "duckdb";
|
|
3906
|
-
const rootDir =
|
|
4139
|
+
const rootDir = path10.resolve(opts.root ?? ".");
|
|
3907
4140
|
const sql = (await resolveSql(opts)).trim();
|
|
3908
4141
|
if (!sql) {
|
|
3909
4142
|
throw new Error("no SQL provided \u2014 pass -e <sql>, -f <file>, or pipe it via stdin");
|
|
3910
4143
|
}
|
|
3911
|
-
await
|
|
4144
|
+
await initConnections();
|
|
3912
4145
|
const cfg = await loadConfig3(rootDir);
|
|
3913
4146
|
try {
|
|
3914
4147
|
let rows = await runTyped(cfg, name, sql);
|
|
3915
4148
|
if (rows === null) {
|
|
3916
|
-
const conn = await cfg.connections.lookupConnection(name);
|
|
4149
|
+
const conn = await withConnectionDiagnostics(() => cfg.connections.lookupConnection(name));
|
|
3917
4150
|
const result = await conn.runSQL(sql);
|
|
3918
4151
|
rows = result?.rows ?? [];
|
|
3919
4152
|
}
|
|
@@ -3931,22 +4164,22 @@ async function sqlCmd(connection, opts) {
|
|
|
3931
4164
|
|
|
3932
4165
|
// src/launch.ts
|
|
3933
4166
|
import { spawn as spawn2 } from "node:child_process";
|
|
3934
|
-
import
|
|
4167
|
+
import fs10 from "node:fs";
|
|
3935
4168
|
import os from "node:os";
|
|
3936
|
-
import
|
|
4169
|
+
import path11 from "node:path";
|
|
3937
4170
|
var SURFACE_FLAG = { author: "--develop", test: "--explore" };
|
|
3938
4171
|
var SERVER_KEY = { author: "malloyyo_author", test: "malloyyo_test" };
|
|
3939
4172
|
async function launchCmd(mode, opts) {
|
|
3940
|
-
const root =
|
|
3941
|
-
const tmpDir =
|
|
3942
|
-
const cfgPath =
|
|
4173
|
+
const root = path11.resolve(opts.root ?? process.cwd());
|
|
4174
|
+
const tmpDir = fs10.mkdtempSync(path11.join(os.tmpdir(), "malloyyo-launch-"));
|
|
4175
|
+
const cfgPath = path11.join(tmpDir, "mcp.json");
|
|
3943
4176
|
const cfg = {
|
|
3944
4177
|
mcpServers: {
|
|
3945
4178
|
// Absolute -C: an ephemeral config, so pinning the root is robust.
|
|
3946
4179
|
[SERVER_KEY[mode]]: { command: "malloyyo", args: ["mcp", SURFACE_FLAG[mode], "-C", root] }
|
|
3947
4180
|
}
|
|
3948
4181
|
};
|
|
3949
|
-
|
|
4182
|
+
fs10.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
|
|
3950
4183
|
const label = mode === "author" ? "AUTHOR (compile/edit)" : "TEST (claude.ai web preview)";
|
|
3951
4184
|
process.stderr.write(`\u25B6 launching Claude in ${label} mode over ${root}
|
|
3952
4185
|
`);
|
|
@@ -3965,11 +4198,11 @@ async function launchCmd(mode, opts) {
|
|
|
3965
4198
|
});
|
|
3966
4199
|
child.on("exit", () => resolve3());
|
|
3967
4200
|
});
|
|
3968
|
-
|
|
4201
|
+
fs10.rmSync(tmpDir, { recursive: true, force: true });
|
|
3969
4202
|
}
|
|
3970
4203
|
|
|
3971
4204
|
// package.json
|
|
3972
|
-
var version = "0.2.
|
|
4205
|
+
var version = "0.2.29";
|
|
3973
4206
|
|
|
3974
4207
|
// src/index.ts
|
|
3975
4208
|
function shortSha(sha) {
|