@tanqory/dev-mcp 0.0.0-stage → 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +94 -2
  3. package/bin/tanqory-dev-mcp.mjs +16 -0
  4. package/bundled/examples/blank/config/settings.json +3 -0
  5. package/bundled/examples/blank/locales/en.json +22 -0
  6. package/bundled/examples/blank/locales/th.json +22 -0
  7. package/bundled/examples/blank/regions/footer.json +35 -0
  8. package/bundled/examples/blank/regions/header.json +13 -0
  9. package/bundled/examples/blank/src/bundle.ts +15 -0
  10. package/bundled/examples/blank/src/components/parts.tsx +32 -0
  11. package/bundled/examples/blank/src/entry.client.tsx +6 -0
  12. package/bundled/examples/blank/src/entry.server.tsx +13 -0
  13. package/bundled/examples/blank/src/sections/chrome.tsx +132 -0
  14. package/bundled/examples/blank/src/sections/commerce.tsx +242 -0
  15. package/bundled/examples/blank/src/sections/content.tsx +111 -0
  16. package/bundled/examples/blank/src/sections/editorial.tsx +107 -0
  17. package/bundled/examples/blank/src/settings.ts +15 -0
  18. package/bundled/examples/blank/src/styles/theme.css +195 -0
  19. package/bundled/examples/blank/src/templates.ts +14 -0
  20. package/bundled/examples/blank/src/theme.tsx +76 -0
  21. package/bundled/examples/blank/template.json +24 -0
  22. package/bundled/examples/blank/templates/404.json +14 -0
  23. package/bundled/examples/blank/templates/account.json +14 -0
  24. package/bundled/examples/blank/templates/article.json +14 -0
  25. package/bundled/examples/blank/templates/blog.json +14 -0
  26. package/bundled/examples/blank/templates/cart.json +14 -0
  27. package/bundled/examples/blank/templates/collection.json +14 -0
  28. package/bundled/examples/blank/templates/index.json +36 -0
  29. package/bundled/examples/blank/templates/page.contact.json +18 -0
  30. package/bundled/examples/blank/templates/page.json +14 -0
  31. package/bundled/examples/blank/templates/product.json +18 -0
  32. package/bundled/examples/blank/templates/search.json +14 -0
  33. package/bundled/examples/blank/tests/theme.test.ts +49 -0
  34. package/bundled/theme-abi/contracts/render-context.v2.d.ts +849 -0
  35. package/bundled/theme-abi/contracts/theme-abi-v2.md +1833 -0
  36. package/bundled/theme-abi/generated/capabilities.json +80 -0
  37. package/bundled/theme-abi/generated/field-types.json +869 -0
  38. package/bundled/theme-abi/generated/limits.json +173 -0
  39. package/bundled/theme-abi/schemas/conformance-report.v2.schema.json +482 -0
  40. package/bundled/theme-abi/schemas/theme-artifact.v1.schema.json +230 -0
  41. package/bundled/theme-abi/schemas/theme-manifest.v2.schema.json +254 -0
  42. package/bundled/theme-conformance/docs/storefront-tier.md +221 -0
  43. package/bundled/theme-conformance/schema/conformance-report.v2.schema.json +482 -0
  44. package/bundled/theme-conformance/schema/storefront-report.v1.schema.json +110 -0
  45. package/bundled/theme-conformance/schema/x-tanqory-ai.v0.schema.json +70 -0
  46. package/bundled/theme-kit/docs/headless-api.md +220 -0
  47. package/bundled/theme-kit/generated/kit-strings.json +180 -0
  48. package/bundled/theme-kit/types/headless/index.d.ts +36 -0
  49. package/bundled/theme-kit/types/headless/theme.d.ts +99 -0
  50. package/bundled/theme-kit/types/sdk/schema.d.ts +197 -0
  51. package/bundled/versions.json +8 -0
  52. package/package.json +53 -3
  53. package/src/bm25.mjs +69 -0
  54. package/src/browser.mjs +123 -0
  55. package/src/cli.mjs +127 -0
  56. package/src/devserver.mjs +82 -0
  57. package/src/docs.mjs +329 -0
  58. package/src/index.mjs +33 -0
  59. package/src/kit.mjs +150 -0
  60. package/src/manifest.mjs +82 -0
  61. package/src/prompts.mjs +97 -0
  62. package/src/scaffold.mjs +471 -0
  63. package/src/schemas.mjs +127 -0
  64. package/src/server.mjs +595 -0
  65. package/src/theme.mjs +91 -0
@@ -0,0 +1,197 @@
1
+ /**
2
+ * The settings schema DSL — the ONE place a Nova section or block declares its
3
+ * persisted settings in code. Everything else is derived from it:
4
+ *
5
+ * schema ──► TypeScript prop types (SettingsOf<typeof schema>)
6
+ * ──► the adapter (resolveSettings: defaults, legacy, coercion)
7
+ * ──► Storybook controls (stories/argTypes.ts)
8
+ * ──► contract drift validation (validate.ts, against generated/*.json)
9
+ *
10
+ * Option IDs here are the PERSISTED IDs (what a store's saved JSON holds), never
11
+ * display labels. Labels come from the approved contract at display time.
12
+ */
13
+ /** The editor control vocabulary used by the approved contracts. */
14
+ export type Control = 'select' | 'boolean' | 'text' | 'textarea' | 'richtext' | 'url' | 'range' | 'number' | 'color' | 'image' | 'video' | 'menu' | 'collection' | 'product' | 'page' | 'blog' | 'article';
15
+ /**
16
+ * How a persisted setting reaches the component:
17
+ * prop — passed through as the typed component prop of the same name
18
+ * adapter — resolved by the adapter layer into data (a menu handle becomes a
19
+ * Menu object, an image id an Image). The component takes the data.
20
+ */
21
+ export type Binding = 'prop' | 'adapter';
22
+ interface FieldBase<C extends Control, V, B extends Binding = 'prop'> {
23
+ readonly control: C;
24
+ readonly default: V;
25
+ readonly binding: B;
26
+ /** LEGACY-HIDDEN: saved values keep rendering; not a control for new stores. */
27
+ readonly legacyHidden?: boolean;
28
+ /** The semantic this setting controls — two visible fields must never share one. */
29
+ readonly semantic?: string;
30
+ /** Why a field that is not in the approved contract exists. Absent = it must be in the contract. */
31
+ readonly extension?: string;
32
+ /** The editor's label for the control (a theme's manifest build reads it; the key, humanised, when absent). */
33
+ readonly label?: string;
34
+ /** Helper text under the control. */
35
+ readonly info?: string;
36
+ /** Placeholder text in the control. */
37
+ readonly placeholder?: string;
38
+ /** The editor panel group the control is filed under. */
39
+ readonly group?: string;
40
+ }
41
+ export interface SelectField<O extends string = string> extends FieldBase<'select', O> {
42
+ readonly options: readonly O[];
43
+ /** The editor's label for each option id (the id itself when absent). */
44
+ readonly optionLabels?: Readonly<Partial<Record<O, string>>>;
45
+ /** Saved values that are no longer options → the option they render as. */
46
+ readonly legacy?: Readonly<Record<string, O>>;
47
+ }
48
+ export interface BooleanField extends FieldBase<'boolean', boolean> {
49
+ }
50
+ export interface TextField<C extends 'text' | 'textarea' | 'richtext' | 'url' | 'color' = 'text'> extends FieldBase<C, string> {
51
+ }
52
+ export interface RangeField extends FieldBase<'range' | 'number', number> {
53
+ readonly min: number;
54
+ readonly max: number;
55
+ readonly step: number;
56
+ /** Values the design snaps to (e.g. spacer 8·12·16·24…). */
57
+ readonly snap?: readonly number[];
58
+ /** The unit the editor shows beside a range ("px", "%"). */
59
+ readonly unit?: string;
60
+ }
61
+ export type ResourceControl = 'image' | 'video' | 'menu' | 'collection' | 'product' | 'page' | 'blog' | 'article';
62
+ export interface ResourceField<C extends ResourceControl = ResourceControl> extends FieldBase<C, string, 'adapter'> {
63
+ }
64
+ export type Field = SelectField | BooleanField | TextField<'text' | 'textarea' | 'richtext' | 'url' | 'color'> | RangeField | ResourceField;
65
+ type Opts = {
66
+ legacyHidden?: boolean;
67
+ semantic?: string;
68
+ extension?: string;
69
+ label?: string;
70
+ info?: string;
71
+ placeholder?: string;
72
+ group?: string;
73
+ };
74
+ declare function select<const O extends string>(options: readonly O[], def: NoInfer<O>): SelectField<O>;
75
+ declare function select<const O extends string, const X extends Opts & {
76
+ legacy?: Record<string, NoInfer<O>>;
77
+ optionLabels?: Readonly<Partial<Record<NoInfer<O>, string>>>;
78
+ }>(options: readonly O[], def: NoInfer<O>, opts: X): SelectField<O> & X;
79
+ declare function resource<const C extends ResourceControl>(control: C, def?: string): ResourceField<C>;
80
+ declare function resource<const C extends ResourceControl, const X extends Opts>(control: C, def: string, opts: X): ResourceField<C> & X;
81
+ export declare const field: {
82
+ select: typeof select;
83
+ boolean: {
84
+ (def?: boolean | undefined): BooleanField;
85
+ <const X extends Opts>(def: boolean, opts: X): BooleanField & X;
86
+ };
87
+ text: {
88
+ (def?: string | undefined): TextField<"text">;
89
+ <const X extends Opts>(def: string, opts: X): TextField<"text"> & X;
90
+ };
91
+ textarea: {
92
+ (def?: string | undefined): TextField<"textarea">;
93
+ <const X extends Opts>(def: string, opts: X): TextField<"textarea"> & X;
94
+ };
95
+ richtext: {
96
+ (def?: string | undefined): TextField<"richtext">;
97
+ <const X extends Opts>(def: string, opts: X): TextField<"richtext"> & X;
98
+ };
99
+ url: {
100
+ (def?: string | undefined): TextField<"url">;
101
+ <const X extends Opts>(def: string, opts: X): TextField<"url"> & X;
102
+ };
103
+ color: {
104
+ (def?: string | undefined): TextField<"color">;
105
+ <const X extends Opts>(def: string, opts: X): TextField<"color"> & X;
106
+ };
107
+ range(def: number, min: number, max: number, step: number, opts?: Opts & {
108
+ snap?: readonly number[];
109
+ unit?: string;
110
+ }): RangeField;
111
+ /** A number input (contract control "number") — same resolution as range. */
112
+ number(def: number, min: number, max: number, step: number, opts?: Opts): RangeField;
113
+ /** A data picker: the persisted value is a handle/id; the adapter resolves it. */
114
+ resource: typeof resource;
115
+ };
116
+ export type Fields = Readonly<Record<string, Field>>;
117
+ /** Where a schema came from: section or block, and which design family owns it. */
118
+ export interface SchemaMeta {
119
+ /** global = a theme setting group (Nova 13 popup), not placed on a page. */
120
+ readonly kind: 'section' | 'block' | 'global';
121
+ readonly name: string;
122
+ /** Design family, e.g. '12A'. The validator reads the contract from here. */
123
+ readonly family: string;
124
+ /** Blocks a section accepts (sections only). */
125
+ readonly blocks?: readonly string[];
126
+ /** Blocks still accepted for saved stores but hidden from the picker. */
127
+ readonly legacyBlocks?: readonly string[];
128
+ /** Sections a block may be placed in (blocks only). */
129
+ readonly parents?: readonly string[];
130
+ /**
131
+ * Earlier persisted names of this block (blocks only). Saved content may still
132
+ * hold a node of that type: the section keeps accepting it (`legacyBlocks`),
133
+ * the theme renders it as this block, and the contract validator reads this
134
+ * block's contract under the old name. New content is written with `name`.
135
+ */
136
+ readonly legacyNames?: readonly string[];
137
+ /** How the editor names the block in "Add block" (blocks). */
138
+ readonly title?: string;
139
+ /** One line for the editor's picker. */
140
+ readonly description?: string;
141
+ readonly icon?: string;
142
+ /** Route context the block needs (`product`, `collection`, …): it is offered only where the page has it. */
143
+ readonly requiresContext?: readonly string[];
144
+ }
145
+ export interface Schema<F extends Fields = Fields> extends SchemaMeta {
146
+ readonly fields: F;
147
+ }
148
+ export declare function defineSchema<const F extends Fields>(meta: SchemaMeta, fields: F): Schema<F>;
149
+ type ValueOf<F> = F extends SelectField<infer O> ? O : F extends BooleanField ? boolean : F extends RangeField ? number : string;
150
+ /**
151
+ * The resolved settings of a schema: every visible field required (the adapter
152
+ * fills defaults), every LEGACY-HIDDEN field optional.
153
+ */
154
+ export type SettingsOf<S extends Schema> = {
155
+ -readonly [K in keyof S['fields'] as S['fields'][K] extends {
156
+ binding: 'adapter';
157
+ } ? never : S['fields'][K] extends {
158
+ legacyHidden: true;
159
+ } ? never : K]: ValueOf<S['fields'][K]>;
160
+ } & {
161
+ -readonly [K in keyof S['fields'] as S['fields'][K] extends {
162
+ binding: 'adapter';
163
+ } ? never : S['fields'][K] extends {
164
+ legacyHidden: true;
165
+ } ? K : never]?: ValueOf<S['fields'][K]>;
166
+ };
167
+ /** The raw adapter-bound (handle) values of a schema. */
168
+ export type HandlesOf<S extends Schema> = {
169
+ -readonly [K in keyof S['fields'] as S['fields'][K] extends {
170
+ binding: 'adapter';
171
+ } ? K : never]: string;
172
+ };
173
+ export interface SettingIssue {
174
+ readonly key: string;
175
+ readonly code: 'unknown-option' | 'legacy-value' | 'coerced' | 'unknown-setting' | 'out-of-range';
176
+ readonly value: unknown;
177
+ readonly resolved?: unknown;
178
+ }
179
+ export interface Resolved<S extends Schema> {
180
+ readonly settings: SettingsOf<S>;
181
+ readonly handles: HandlesOf<S>;
182
+ /** Everything the adapter had to correct — surfaced in dev and asserted in tests. */
183
+ readonly issues: readonly SettingIssue[];
184
+ }
185
+ /**
186
+ * The adapter core: saved JSON settings → typed, complete settings.
187
+ *
188
+ * - missing → the field default
189
+ * - a legacy option → the option it renders as (issue: legacy-value)
190
+ * - an unknown option → the default (issue: unknown-option), never a crash
191
+ * - "true"/"false"/"32" strings → boolean / number (issue: coerced)
192
+ * - LEGACY-HIDDEN fields keep their saved value (that is the point of them)
193
+ */
194
+ export declare function resolveSettings<S extends Schema>(schema: S, raw: Readonly<Record<string, unknown>> | undefined): Resolved<S>;
195
+ /** Defaults of a schema — what a brand-new section renders with. */
196
+ export declare function defaultsOf<S extends Schema>(schema: S): SettingsOf<S>;
197
+ export {};
@@ -0,0 +1,8 @@
1
+ {
2
+ "@tanqory/theme-abi": "2.0.0-alpha.14",
3
+ "@tanqory/theme-conformance": "0.1.0-alpha.14",
4
+ "@tanqory/theme-kit": "2.0.0-alpha.3",
5
+ "examples": {
6
+ "blank": "create-tanqory-theme templates/blank"
7
+ }
8
+ }
package/package.json CHANGED
@@ -1,6 +1,56 @@
1
1
  {
2
2
  "name": "@tanqory/dev-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0-alpha.1",
4
+ "description": "Local MCP server (stdio) for building Tanqory storefront themes with coding agents: versioned docs and schemas, kit examples, section scaffolding, theme checks, and pages and screenshots of the running dev server.",
5
+ "keywords": [
6
+ "tanqory",
7
+ "theme",
8
+ "storefront",
9
+ "mcp",
10
+ "model-context-protocol",
11
+ "claude-code",
12
+ "codex"
13
+ ],
14
+ "license": "MIT",
15
+ "author": "Tanqory",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "https://github.com/tanqory/tanqory-platform-packages.git",
19
+ "directory": "packages/tools/dev-mcp"
20
+ },
21
+ "type": "module",
22
+ "main": "./src/index.mjs",
23
+ "exports": {
24
+ ".": "./src/index.mjs",
25
+ "./package.json": "./package.json"
26
+ },
27
+ "bin": {
28
+ "tanqory-dev-mcp": "./bin/tanqory-dev-mcp.mjs"
29
+ },
30
+ "files": [
31
+ "bin",
32
+ "src",
33
+ "bundled",
34
+ "README.md",
35
+ "LICENSE"
36
+ ],
37
+ "engines": {
38
+ "node": ">=20"
39
+ },
40
+ "dependencies": {
41
+ "@modelcontextprotocol/sdk": "^1.32.1",
42
+ "playwright-core": "^1.56.0",
43
+ "zod": "^3.25.76"
44
+ },
45
+ "devDependencies": {
46
+ "create-tanqory-theme": "0.2.1"
47
+ },
48
+ "publishConfig": {
49
+ "registry": "https://npm.pkg.github.com"
50
+ },
51
+ "scripts": {
52
+ "test": "node --test test/*.test.mjs",
53
+ "check:public-files": "node scripts/check-public-files.mjs",
54
+ "sync:bundled": "node scripts/sync-bundled.mjs"
55
+ }
6
56
  }
package/src/bm25.mjs ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Okapi BM25 over a fixed corpus: deterministic, in memory, no network, no embeddings. Same input, same ranking
3
+ * (ties broken by corpus order).
4
+ */
5
+ const STOP = new Set(
6
+ 'a an and are as at be by can do does for from how i in into is it its of on or that the this to was what when where which who why will with you your'.split(
7
+ ' ',
8
+ ),
9
+ )
10
+
11
+ /** Lowercased terms; camelCase and compound identifiers (data-tq-node, useCart) also yield their parts. */
12
+ export function tokenize(text) {
13
+ const out = []
14
+ for (const raw of String(text).match(/[\p{L}\p{N}][\p{L}\p{N}_.:-]*/gu) ?? []) {
15
+ const word = raw.replace(/[.:_-]+$/, '')
16
+ const lower = word.toLowerCase()
17
+ const parts = word
18
+ .split(/[._:-]+/)
19
+ .flatMap((p) => p.split(/(?<=[\p{Ll}\p{N}])(?=\p{Lu})/u))
20
+ .map((p) => p.toLowerCase())
21
+ .filter(Boolean)
22
+ if (parts.length > 1 && !STOP.has(lower)) out.push(lower)
23
+ for (const p of parts) if (!STOP.has(p) && (p.length > 1 || /\p{N}/u.test(p))) out.push(p)
24
+ }
25
+ return out
26
+ }
27
+
28
+ export class Bm25 {
29
+ /** @param {{ title: string, text: string }[]} docs */
30
+ constructor(docs, { k1 = 1.2, b = 0.75, titleWeight = 3 } = {}) {
31
+ this.k1 = k1
32
+ this.b = b
33
+ this.tf = []
34
+ this.len = []
35
+ this.df = new Map()
36
+ for (const d of docs) {
37
+ const terms = [...tokenize(d.text)]
38
+ const title = tokenize(d.title)
39
+ for (let i = 0; i < titleWeight; i++) terms.push(...title)
40
+ const tf = new Map()
41
+ for (const t of terms) tf.set(t, (tf.get(t) ?? 0) + 1)
42
+ for (const t of tf.keys()) this.df.set(t, (this.df.get(t) ?? 0) + 1)
43
+ this.tf.push(tf)
44
+ this.len.push(terms.length)
45
+ }
46
+ this.n = docs.length
47
+ this.avg = this.len.reduce((a, b2) => a + b2, 0) / Math.max(1, this.n)
48
+ }
49
+
50
+ /** @returns {{ index: number, score: number }[]} best first */
51
+ search(query, limit = 10) {
52
+ const q = [...new Set(tokenize(query))]
53
+ if (!q.length) return []
54
+ const scores = []
55
+ for (let i = 0; i < this.n; i++) {
56
+ let s = 0
57
+ for (const t of q) {
58
+ const f = this.tf[i].get(t)
59
+ if (!f) continue
60
+ const df = this.df.get(t)
61
+ const idf = Math.log(1 + (this.n - df + 0.5) / (df + 0.5))
62
+ s += (idf * f * (this.k1 + 1)) / (f + this.k1 * (1 - this.b + (this.b * this.len[i]) / this.avg))
63
+ }
64
+ if (s > 0) scores.push({ index: i, score: s })
65
+ }
66
+ scores.sort((x, y) => y.score - x.score || x.index - y.index)
67
+ return scores.slice(0, limit)
68
+ }
69
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * One headless Chromium per server process, started on first use. playwright-core is resolved from the theme
3
+ * first (its Chromium is usually already downloaded by `npm run check`), else from this package. If no browser
4
+ * binary exists it is downloaded once with Playwright's own installer, unless TANQORY_MCP_NO_BROWSER_DOWNLOAD=1.
5
+ * TANQORY_CHROMIUM_PATH uses an existing Chrome/Chromium instead.
6
+ */
7
+ import { spawnSync } from 'node:child_process'
8
+ import { createRequire } from 'node:module'
9
+ import { dirname, join } from 'node:path'
10
+ import { pathToFileURL } from 'node:url'
11
+
12
+ export const VIEWPORTS = {
13
+ mobile: { width: 390, height: 844, deviceScaleFactor: 2, isMobile: true, hasTouch: true },
14
+ desktop: { width: 1280, height: 900, deviceScaleFactor: 1, isMobile: false, hasTouch: false },
15
+ }
16
+
17
+ export class BrowserUnavailable extends Error {
18
+ constructor(message, hint) {
19
+ super(message)
20
+ this.code = 'browser_unavailable'
21
+ this.hint = hint
22
+ }
23
+ }
24
+
25
+ const HINT = 'Run `npx playwright install chromium` (on Linux add `--with-deps`) or set TANQORY_CHROMIUM_PATH to a Chrome/Chromium binary, then call the tool again.'
26
+
27
+ async function loadPlaywright(root) {
28
+ const bases = [root && join(root, 'package.json'), import.meta.url].filter(Boolean)
29
+ for (const base of bases) {
30
+ try {
31
+ const req = createRequire(base)
32
+ const main = req.resolve('playwright-core')
33
+ const mod = await import(pathToFileURL(main).href)
34
+ const chromium = mod.chromium ?? mod.default?.chromium
35
+ if (chromium) return { chromium, dir: dirname(req.resolve('playwright-core/package.json')), from: base === import.meta.url ? 'dev-mcp' : 'theme' }
36
+ } catch {
37
+ // next base
38
+ }
39
+ }
40
+ throw new BrowserUnavailable('playwright-core could not be loaded.', 'Reinstall @tanqory/dev-mcp (it depends on playwright-core).')
41
+ }
42
+
43
+ export class Browser {
44
+ constructor(root, log = () => {}) {
45
+ this.root = root
46
+ this.log = log
47
+ this.pending = null
48
+ }
49
+
50
+ async launch() {
51
+ const pw = await loadPlaywright(this.root)
52
+ const opts = { headless: true, ...(process.env.TANQORY_CHROMIUM_PATH ? { executablePath: process.env.TANQORY_CHROMIUM_PATH } : {}) }
53
+ try {
54
+ return await pw.chromium.launch(opts)
55
+ } catch (e) {
56
+ const missing = /Executable doesn't exist|browserType\.launch: .*(not found|ENOENT)/i.test(e.message)
57
+ if (!missing || opts.executablePath || process.env.TANQORY_MCP_NO_BROWSER_DOWNLOAD === '1')
58
+ throw new BrowserUnavailable(`Headless Chromium could not start: ${e.message.split('\n')[0]}`, HINT)
59
+ this.log(`Chromium is not installed for playwright-core (${pw.from}); downloading it once…`)
60
+ const r = spawnSync(process.execPath, [join(pw.dir, 'cli.js'), 'install', 'chromium'], { stdio: ['ignore', 2, 2], timeout: 10 * 60 * 1000 })
61
+ if (r.status !== 0) throw new BrowserUnavailable('Headless Chromium is not installed and the download failed.', HINT)
62
+ try {
63
+ return await pw.chromium.launch(opts)
64
+ } catch (e2) {
65
+ throw new BrowserUnavailable(`Headless Chromium could not start after the download: ${e2.message.split('\n')[0]}`, HINT)
66
+ }
67
+ }
68
+ }
69
+
70
+ async browser() {
71
+ if (!this.pending) {
72
+ this.pending = this.launch()
73
+ this.pending.catch(() => {
74
+ this.pending = null
75
+ })
76
+ }
77
+ return this.pending
78
+ }
79
+
80
+ /**
81
+ * Load `url` and run `fn(page)`; collects console errors, uncaught exceptions and failed same-origin requests.
82
+ * @template T
83
+ * @param {string} url
84
+ * @param {{ viewport?: keyof VIEWPORTS | { width: number, height: number } }} o
85
+ * @param {(page: any) => Promise<T>} fn
86
+ */
87
+ async withPage(url, { viewport = 'desktop' } = {}, fn) {
88
+ const b = await this.browser()
89
+ const vp = typeof viewport === 'string' ? VIEWPORTS[viewport] : { width: viewport.width, height: viewport.height, deviceScaleFactor: 1 }
90
+ const context = await b.newContext({ viewport: { width: vp.width, height: vp.height }, deviceScaleFactor: vp.deviceScaleFactor, isMobile: vp.isMobile ?? false, hasTouch: vp.hasTouch ?? false })
91
+ const errors = []
92
+ const origin = new URL(url).origin
93
+ try {
94
+ const page = await context.newPage()
95
+ page.on('console', (m) => {
96
+ if (m.type() === 'error') errors.push({ kind: 'console', message: m.text().slice(0, 1000), location: m.location()?.url ? `${m.location().url}:${m.location().lineNumber ?? 0}` : undefined })
97
+ })
98
+ page.on('pageerror', (e) => errors.push({ kind: 'exception', message: String(e.message ?? e).slice(0, 1000) }))
99
+ page.on('response', (r) => {
100
+ if (r.status() >= 400 && r.url().startsWith(origin) && r.request().resourceType() !== 'document') errors.push({ kind: 'request', message: `${r.status()} ${r.request().method()} ${r.url().slice(origin.length)}` })
101
+ })
102
+ let status = null
103
+ try {
104
+ const res = await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 })
105
+ status = res?.status() ?? null
106
+ } catch (e) {
107
+ if (!/Timeout/i.test(e.message)) throw e
108
+ errors.push({ kind: 'timeout', message: 'The page did not reach network idle within 30 s; captured as it was.' })
109
+ }
110
+ await page.waitForTimeout(250)
111
+ const result = await fn(page)
112
+ return { status, errors, viewport: { width: vp.width, height: vp.height }, result }
113
+ } finally {
114
+ await context.close().catch(() => {})
115
+ }
116
+ }
117
+
118
+ async close() {
119
+ const p = this.pending
120
+ this.pending = null
121
+ if (p) await p.then((b) => b.close()).catch(() => {})
122
+ }
123
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The Tanqory CLI is the engine; this server only calls it. Contract (CLI v2):
3
+ *
4
+ * tanqory <cmd> --json → exactly one JSON object on stdout:
5
+ * { ok: boolean, command: string, data?: any, error?: { code, message, hint? } }
6
+ * logs on stderr.
7
+ *
8
+ * Which CLI: the one that started this server (`tanqory mcp` passes itself), else TANQORY_CLI, else the theme's
9
+ * node_modules/.bin/tanqory, else `tanqory` on PATH. Never `npx` at call time: a check must not silently download
10
+ * a different CLI than the developer installed.
11
+ */
12
+ import { spawn } from 'node:child_process'
13
+ import { accessSync, constants, existsSync } from 'node:fs'
14
+ import { delimiter, join } from 'node:path'
15
+
16
+ const JS = /\.(m|c)?js$/
17
+
18
+ function onPath(name) {
19
+ for (const dir of (process.env.PATH ?? '').split(delimiter)) {
20
+ if (!dir) continue
21
+ for (const ext of process.platform === 'win32' ? ['.cmd', '.exe', ''] : ['']) {
22
+ const p = join(dir, name + ext)
23
+ try {
24
+ accessSync(p, constants.X_OK)
25
+ return p
26
+ } catch {
27
+ // next
28
+ }
29
+ }
30
+ }
31
+ return null
32
+ }
33
+
34
+ /** @returns {{ command: string, args: string[], via: string } | null} */
35
+ export function resolveCli({ root, cli } = {}) {
36
+ if (cli) return { ...cli, args: cli.args ?? [], via: cli.via ?? 'host' }
37
+ const env = process.env.TANQORY_CLI
38
+ if (env) {
39
+ if (env.trim().startsWith('[')) {
40
+ const [command, ...args] = JSON.parse(env)
41
+ return { command, args, via: 'TANQORY_CLI' }
42
+ }
43
+ return JS.test(env) ? { command: process.execPath, args: [env], via: 'TANQORY_CLI' } : { command: env, args: [], via: 'TANQORY_CLI' }
44
+ }
45
+ if (root) {
46
+ const local = join(root, 'node_modules', '.bin', process.platform === 'win32' ? 'tanqory.cmd' : 'tanqory')
47
+ if (existsSync(local)) return { command: local, args: [], via: 'theme node_modules' }
48
+ }
49
+ const global = onPath('tanqory')
50
+ return global ? { command: global, args: [], via: 'PATH' } : null
51
+ }
52
+
53
+ export class CliError extends Error {
54
+ constructor(code, message, hint, extra = {}) {
55
+ super(message)
56
+ this.code = code
57
+ this.hint = hint
58
+ Object.assign(this, extra)
59
+ }
60
+ }
61
+
62
+ const MAX_STDOUT = 16 * 1024 * 1024
63
+
64
+ /** Pull the contract envelope out of stdout: the whole of it, else the last line that parses as one. */
65
+ export function parseEnvelope(stdout) {
66
+ const text = stdout.trim()
67
+ const candidates = [text, ...text.split('\n').reverse()]
68
+ for (const c of candidates) {
69
+ try {
70
+ const v = JSON.parse(c)
71
+ if (v && typeof v === 'object' && typeof v.ok === 'boolean' && typeof v.command === 'string') return v
72
+ } catch {
73
+ // next
74
+ }
75
+ }
76
+ return null
77
+ }
78
+
79
+ /**
80
+ * Run `tanqory <args> --json` in the theme root. Resolves the envelope (ok true or false); rejects with CliError
81
+ * only when there is no usable answer (no CLI, timeout, not the JSON contract).
82
+ */
83
+ export function runCli(args, { root, cli, timeoutMs = 10 * 60 * 1000, signal } = {}) {
84
+ const bin = resolveCli({ root, cli })
85
+ if (!bin)
86
+ return Promise.reject(
87
+ new CliError('cli_not_found', 'The Tanqory CLI (`tanqory`) was not found: not in this theme\'s node_modules and not on PATH.', 'Install it in the theme (`npm install --save-dev @tanqory/cli`) or start this server with `npx -y @tanqory/cli mcp`. TANQORY_CLI may point at a CLI binary.'),
88
+ )
89
+ const argv = [...bin.args, ...args, '--json']
90
+ return new Promise((resolve, reject) => {
91
+ const child = spawn(bin.command, argv, { cwd: root ?? process.cwd(), env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' }, stdio: ['ignore', 'pipe', 'pipe'], signal })
92
+ let out = ''
93
+ let err = ''
94
+ let killed = false
95
+ const timer = setTimeout(() => {
96
+ killed = true
97
+ child.kill('SIGTERM')
98
+ setTimeout(() => child.kill('SIGKILL'), 5000).unref()
99
+ }, timeoutMs)
100
+ child.stdout.on('data', (d) => {
101
+ if (out.length < MAX_STDOUT) out += d
102
+ })
103
+ child.stderr.on('data', (d) => {
104
+ err = (err + d).slice(-8000)
105
+ })
106
+ child.on('error', (e) => {
107
+ clearTimeout(timer)
108
+ reject(new CliError('cli_failed_to_start', `Could not start the Tanqory CLI (${bin.command}): ${e.message}`, 'Check the CLI is installed and executable.'))
109
+ })
110
+ child.on('close', (code) => {
111
+ clearTimeout(timer)
112
+ const cmd = `tanqory ${args.join(' ')} --json`
113
+ if (killed) return reject(new CliError('cli_timeout', `\`${cmd}\` did not finish within ${Math.round(timeoutMs / 1000)} s and was stopped.`, 'Run it in a terminal to see where it hangs.', { stderr: err }))
114
+ const env = parseEnvelope(out)
115
+ if (!env)
116
+ return reject(
117
+ new CliError(
118
+ 'cli_protocol_error',
119
+ `\`${cmd}\` exited ${code} without the JSON result this server expects on stdout.`,
120
+ 'This server needs Tanqory CLI v2 (`--json` on every command). Update @tanqory/cli; until then run the command in a terminal.',
121
+ { stderr: err.trim().split('\n').slice(-20).join('\n'), exitCode: code },
122
+ ),
123
+ )
124
+ resolve({ ...env, exitCode: code, stderrTail: err.trim().split('\n').slice(-20).join('\n'), via: bin.via })
125
+ })
126
+ })
127
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The running `tanqory theme dev` server. Contract (CLI v2):
3
+ *
4
+ * .tanqory/dev-server.json { pid, url, port, store: { id, handle }, mode: "store" | "mock", startedAt }
5
+ * GET /__tq/status { ok, theme: { name, manifestDigest }, lastBuild: { ok, at, errors: [{ file, line, message }] } }
6
+ * GET /__tq/page?path=&locale=&market= { status, html, consoleErrors: [], serverLogs: [] }
7
+ * GET /__tq/store-summary what the store has to design against
8
+ *
9
+ * Only loopback URLs are used: the server never fetches a remote host because a file said so.
10
+ */
11
+ import { readFileSync } from 'node:fs'
12
+ import { join } from 'node:path'
13
+
14
+ export const DEV_SERVER_FILE = join('.tanqory', 'dev-server.json')
15
+ const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]', '::1'])
16
+
17
+ export class DevServerError extends Error {
18
+ constructor(code, message, hint) {
19
+ super(message)
20
+ this.code = code
21
+ this.hint = hint
22
+ }
23
+ }
24
+
25
+ const alive = (pid) => {
26
+ if (!Number.isInteger(pid) || pid <= 0) return null
27
+ try {
28
+ process.kill(pid, 0)
29
+ return true
30
+ } catch (e) {
31
+ return e.code === 'EPERM'
32
+ }
33
+ }
34
+
35
+ const NOT_RUNNING_HINT = 'Start it yourself in the background from the theme folder: `npm run dev -- --mock` (offline fixture store; `npm run dev` when signed in with `npx tanqory login`), wait until dev_server_status says it is running, then call this tool again. Stop it later with `npm run dev -- --stop`, never pkill.'
36
+
37
+ /** The dev-server record, or a reason it cannot be used. Never throws. */
38
+ export function readDevServer(root) {
39
+ if (!root) return { running: false, reason: 'no_theme', hint: 'Open the server in a theme folder (one with tanqory.config.json).' }
40
+ let rec
41
+ try {
42
+ rec = JSON.parse(readFileSync(join(root, DEV_SERVER_FILE), 'utf8'))
43
+ } catch (e) {
44
+ return { running: false, reason: e.code === 'ENOENT' ? 'not_started' : 'unreadable_record', hint: NOT_RUNNING_HINT }
45
+ }
46
+ let url
47
+ try {
48
+ url = new URL(rec.url)
49
+ } catch {
50
+ return { running: false, reason: 'invalid_record', hint: `${DEV_SERVER_FILE} has no valid url. ${NOT_RUNNING_HINT}` }
51
+ }
52
+ if (!LOOPBACK.has(url.hostname) || !/^https?:$/.test(url.protocol))
53
+ return { running: false, reason: 'not_loopback', hint: `${DEV_SERVER_FILE} points at ${url.host}; only a localhost dev server is used.` }
54
+ const pidAlive = alive(rec.pid)
55
+ if (pidAlive === false) return { running: false, reason: 'stale_record', record: rec, hint: `The dev server (pid ${rec.pid}) is no longer running. ${NOT_RUNNING_HINT}` }
56
+ return { running: true, record: { ...rec, url: url.origin } }
57
+ }
58
+
59
+ export async function devGet(origin, path, { timeoutMs = 60000 } = {}) {
60
+ let res
61
+ try {
62
+ res = await fetch(`${origin}${path}`, { signal: AbortSignal.timeout(timeoutMs), headers: { accept: 'application/json' } })
63
+ } catch (e) {
64
+ throw new DevServerError('dev_server_unreachable', `The dev server at ${origin} did not answer ${path.split('?')[0]}: ${e.cause?.code ?? e.name}.`, NOT_RUNNING_HINT)
65
+ }
66
+ const text = await res.text()
67
+ let body
68
+ try {
69
+ body = JSON.parse(text)
70
+ } catch {
71
+ throw new DevServerError('dev_server_protocol_error', `${path.split('?')[0]} answered ${res.status} without JSON.`, 'The dev server needs Tanqory CLI v2; restart `tanqory theme dev` after updating.')
72
+ }
73
+ if (!res.ok && res.status !== 404) throw new DevServerError('dev_server_error', `${path.split('?')[0]} answered ${res.status}: ${body?.error?.message ?? text.slice(0, 200)}`, body?.error?.hint ?? 'See the `tanqory theme dev` terminal.')
74
+ return { status: res.status, body }
75
+ }
76
+
77
+ /** Require a running dev server; throws DevServerError otherwise. */
78
+ export function requireDevServer(root) {
79
+ const s = readDevServer(root)
80
+ if (!s.running) throw new DevServerError('dev_server_not_running', `No running dev server (${s.reason}).`, s.hint)
81
+ return s.record
82
+ }