@texuli/tools 0.0.0-stage → 0.1.0-next.0

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 (115) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +33 -2
  3. package/dist/a2ui/from-a2ui.d.ts +35 -0
  4. package/dist/a2ui/from-a2ui.js +180 -0
  5. package/dist/a2ui/index.d.ts +11 -0
  6. package/dist/a2ui/index.js +11 -0
  7. package/dist/a2ui/props.d.ts +10 -0
  8. package/dist/a2ui/props.js +94 -0
  9. package/dist/a2ui/schema.d.ts +26 -0
  10. package/dist/a2ui/schema.js +193 -0
  11. package/dist/a2ui/to-a2ui.d.ts +30 -0
  12. package/dist/a2ui/to-a2ui.js +119 -0
  13. package/dist/a2ui/types.d.ts +71 -0
  14. package/dist/a2ui/types.js +11 -0
  15. package/dist/cli-add/add.d.ts +21 -0
  16. package/dist/cli-add/add.js +241 -0
  17. package/dist/cli-add/cli.d.ts +25 -0
  18. package/dist/cli-add/cli.js +157 -0
  19. package/dist/cli-add/index.d.ts +12 -0
  20. package/dist/cli-add/index.js +11 -0
  21. package/dist/cli-add/paths.d.ts +4 -0
  22. package/dist/cli-add/paths.js +4 -0
  23. package/dist/cli-add/provenance.d.ts +32 -0
  24. package/dist/cli-add/provenance.js +78 -0
  25. package/dist/cli-add/registry.d.ts +19 -0
  26. package/dist/cli-add/registry.js +50 -0
  27. package/dist/cli-add/types.d.ts +71 -0
  28. package/dist/cli-add/types.js +4 -0
  29. package/dist/cli-init/cli.d.ts +25 -0
  30. package/dist/cli-init/cli.js +212 -0
  31. package/dist/cli-init/config.d.ts +10 -0
  32. package/dist/cli-init/config.js +62 -0
  33. package/dist/cli-init/detect.d.ts +13 -0
  34. package/dist/cli-init/detect.js +42 -0
  35. package/dist/cli-init/files.d.ts +18 -0
  36. package/dist/cli-init/files.js +59 -0
  37. package/dist/cli-init/index.d.ts +14 -0
  38. package/dist/cli-init/index.js +13 -0
  39. package/dist/cli-init/init.d.ts +14 -0
  40. package/dist/cli-init/init.js +157 -0
  41. package/dist/cli-init/safe-path.d.ts +2 -0
  42. package/dist/cli-init/safe-path.js +26 -0
  43. package/dist/cli-init/theme-apply.d.ts +2 -0
  44. package/dist/cli-init/theme-apply.js +69 -0
  45. package/dist/cli-init/themes.d.ts +37 -0
  46. package/dist/cli-init/themes.js +83 -0
  47. package/dist/cli-init/types.d.ts +50 -0
  48. package/dist/cli-init/types.js +1 -0
  49. package/dist/cli-init/wire.d.ts +20 -0
  50. package/dist/cli-init/wire.js +90 -0
  51. package/dist/cli-update/cli.d.ts +26 -0
  52. package/dist/cli-update/cli.js +161 -0
  53. package/dist/cli-update/index.d.ts +11 -0
  54. package/dist/cli-update/index.js +10 -0
  55. package/dist/cli-update/lines.d.ts +38 -0
  56. package/dist/cli-update/lines.js +189 -0
  57. package/dist/cli-update/provenance.d.ts +14 -0
  58. package/dist/cli-update/provenance.js +51 -0
  59. package/dist/cli-update/types.d.ts +83 -0
  60. package/dist/cli-update/types.js +1 -0
  61. package/dist/cli-update/update.d.ts +12 -0
  62. package/dist/cli-update/update.js +261 -0
  63. package/dist/elements/content.d.ts +15 -0
  64. package/dist/elements/content.js +53 -0
  65. package/dist/elements/define.d.ts +49 -0
  66. package/dist/elements/define.js +281 -0
  67. package/dist/elements/index.d.ts +6 -0
  68. package/dist/elements/index.js +6 -0
  69. package/dist/golden/axe.d.ts +32 -0
  70. package/dist/golden/axe.js +22 -0
  71. package/dist/golden/catalog.d.ts +24 -0
  72. package/dist/golden/catalog.js +85 -0
  73. package/dist/golden/cli.d.ts +9 -0
  74. package/dist/golden/cli.js +45 -0
  75. package/dist/golden/guidance.d.ts +12 -0
  76. package/dist/golden/guidance.js +171 -0
  77. package/dist/golden/index.d.ts +17 -0
  78. package/dist/golden/index.js +17 -0
  79. package/dist/golden/outline.d.ts +21 -0
  80. package/dist/golden/outline.js +68 -0
  81. package/dist/golden/paint.d.ts +21 -0
  82. package/dist/golden/paint.js +103 -0
  83. package/dist/golden/report.d.ts +28 -0
  84. package/dist/golden/report.js +64 -0
  85. package/dist/golden/score.d.ts +16 -0
  86. package/dist/golden/score.js +116 -0
  87. package/dist/golden/sinks.d.ts +8 -0
  88. package/dist/golden/sinks.js +11 -0
  89. package/dist/golden/tasks.d.ts +4 -0
  90. package/dist/golden/tasks.js +686 -0
  91. package/dist/golden/types.d.ts +54 -0
  92. package/dist/golden/types.js +9 -0
  93. package/dist/index.d.ts +8 -0
  94. package/dist/index.js +8 -0
  95. package/dist/internal/paths.d.ts +56 -0
  96. package/dist/internal/paths.js +186 -0
  97. package/dist/llms/bundle.d.ts +6 -0
  98. package/dist/llms/bundle.js +47 -0
  99. package/dist/llms/cli.d.ts +24 -0
  100. package/dist/llms/cli.js +104 -0
  101. package/dist/llms/format.d.ts +35 -0
  102. package/dist/llms/format.js +166 -0
  103. package/dist/llms/index.d.ts +9 -0
  104. package/dist/llms/index.js +8 -0
  105. package/dist/llms/types.d.ts +51 -0
  106. package/dist/llms/types.js +1 -0
  107. package/dist/llms/write.d.ts +14 -0
  108. package/dist/llms/write.js +49 -0
  109. package/dist/mcp/index.d.ts +2 -0
  110. package/dist/mcp/index.js +2 -0
  111. package/dist/mcp/server.d.ts +20 -0
  112. package/dist/mcp/server.js +326 -0
  113. package/dist/mcp/stdio.d.ts +3 -0
  114. package/dist/mcp/stdio.js +34 -0
  115. package/package.json +63 -4
@@ -0,0 +1,119 @@
1
+ import { COMPONENT_ID, copyProps, isPlainRecord } from "./props.js";
2
+ import { validateA2uiMessages } from "./schema.js";
3
+ import { A2UI_ROOT_ID, A2UI_ROOTS_COMPONENT, A2UI_VERSION, } from "./types.js";
4
+ /**
5
+ * Convert a catalog tree (one node or a list of roots) into A2UI v0.9
6
+ * messages: an optional `createSurface`, then one `updateComponents` whose
7
+ * flat component list references children by id.
8
+ *
9
+ * Ids are deterministic: the root is `root` and the rest are `n1`, `n2`… in
10
+ * the order they are visited. Zero or several roots are wrapped in a
11
+ * `texuli-roots` component. Props must be literal JSON; reserved names
12
+ * (`id`, `component`, `children`, `child`), prototype keys, bindings and
13
+ * function calls fail the conversion. The output is checked against
14
+ * {@link A2UI_MESSAGE_SCHEMA} before it is returned.
15
+ */
16
+ export function catalogTreeToA2ui(tree, options) {
17
+ const maxComponents = options.maxComponents ?? 1000;
18
+ const maxDepth = options.maxDepth ?? 64;
19
+ const diagnostics = [];
20
+ const components = [];
21
+ let next = 0;
22
+ let stopped = false;
23
+ const visit = (value, path, depth, id) => {
24
+ if (stopped)
25
+ return;
26
+ if (depth > maxDepth) {
27
+ diagnostics.push({ code: 'limit', path, message: `Nesting deeper than ${maxDepth}.` });
28
+ return;
29
+ }
30
+ if (components.length >= maxComponents) {
31
+ diagnostics.push({ code: 'limit', path, message: `More than ${maxComponents} components.` });
32
+ stopped = true;
33
+ return;
34
+ }
35
+ if (!isPlainRecord(value) || typeof value.id !== 'string' || !COMPONENT_ID.test(value.id)) {
36
+ diagnostics.push({
37
+ code: 'invalid-node',
38
+ path,
39
+ message: 'A catalog node is a plain object with a kebab-case "id".',
40
+ });
41
+ return;
42
+ }
43
+ if (value.id === A2UI_ROOTS_COMPONENT) {
44
+ diagnostics.push({
45
+ code: 'invalid-node',
46
+ path: `${path}.id`,
47
+ message: `"${A2UI_ROOTS_COMPONENT}" is the adapter's own wrapper, not a catalog id.`,
48
+ });
49
+ return;
50
+ }
51
+ if (value.props !== undefined && !isPlainRecord(value.props)) {
52
+ diagnostics.push({
53
+ code: 'invalid-node',
54
+ path: `${path}.props`,
55
+ message: '"props" must be an object.',
56
+ });
57
+ return;
58
+ }
59
+ if (value.children !== undefined && !Array.isArray(value.children)) {
60
+ diagnostics.push({
61
+ code: 'invalid-node',
62
+ path: `${path}.children`,
63
+ message: '"children" must be an array.',
64
+ });
65
+ return;
66
+ }
67
+ const props = copyProps(value.props ?? {}, [], `${path}.props`, 'reserved-prop', diagnostics);
68
+ const component = { id, component: value.id, ...props };
69
+ components.push(component);
70
+ if (Array.isArray(value.children)) {
71
+ const ids = [];
72
+ value.children.forEach((child, i) => {
73
+ if (stopped)
74
+ return;
75
+ next += 1;
76
+ const childId = `n${next}`;
77
+ ids.push(childId);
78
+ visit(child, `${path}.children[${i}]`, depth + 1, childId);
79
+ });
80
+ component.children = ids;
81
+ }
82
+ };
83
+ if (Array.isArray(tree)) {
84
+ if (tree.length === 1)
85
+ visit(tree[0], '$[0]', 1, A2UI_ROOT_ID);
86
+ else if (tree.length >= maxComponents) {
87
+ diagnostics.push({
88
+ code: 'limit',
89
+ path: '$',
90
+ message: `More than ${maxComponents} components.`,
91
+ });
92
+ }
93
+ else {
94
+ const ids = tree.map(() => `n${++next}`);
95
+ components.push({ id: A2UI_ROOT_ID, component: A2UI_ROOTS_COMPONENT, children: ids });
96
+ tree.forEach((node, i) => {
97
+ visit(node, `$[${i}]`, 2, ids[i]);
98
+ });
99
+ }
100
+ }
101
+ else {
102
+ visit(tree, '$', 1, A2UI_ROOT_ID);
103
+ }
104
+ if (diagnostics.length > 0)
105
+ return { ok: false, diagnostics };
106
+ const messages = [];
107
+ if (options.createSurface !== false) {
108
+ messages.push({
109
+ version: A2UI_VERSION,
110
+ createSurface: { surfaceId: options.surfaceId, catalogId: options.catalogId },
111
+ });
112
+ }
113
+ messages.push({
114
+ version: A2UI_VERSION,
115
+ updateComponents: { surfaceId: options.surfaceId, components },
116
+ });
117
+ const invalid = validateA2uiMessages(messages);
118
+ return invalid.length > 0 ? { ok: false, diagnostics: invalid } : { ok: true, messages };
119
+ }
@@ -0,0 +1,71 @@
1
+ /** The A2UI protocol version this adapter reads and writes. */
2
+ export declare const A2UI_VERSION = "v0.9";
3
+ /** The component id A2UI requires as the root of a surface's tree. */
4
+ export declare const A2UI_ROOT_ID = "root";
5
+ /**
6
+ * Component a payload uses as its root when the catalog tree has zero or
7
+ * several top-level nodes. `a2uiToCatalogTree` unwraps it.
8
+ */
9
+ export declare const A2UI_ROOTS_COMPONENT = "texuli-roots";
10
+ /** Keys of an A2UI component that are structure, not props. */
11
+ export declare const A2UI_RESERVED_KEYS: readonly string[];
12
+ /** A static list of child ids, or a data-bound template (not supported here). */
13
+ export type A2uiChildList = readonly string[] | {
14
+ componentId: string;
15
+ path: string;
16
+ };
17
+ /** One flat A2UI component: id, type and its props side by side. */
18
+ export interface A2uiComponent {
19
+ id: string;
20
+ component: string;
21
+ children?: A2uiChildList;
22
+ child?: string;
23
+ [prop: string]: unknown;
24
+ }
25
+ export interface A2uiCreateSurfaceMessage {
26
+ version: typeof A2UI_VERSION;
27
+ createSurface: {
28
+ surfaceId: string;
29
+ catalogId: string;
30
+ theme?: Record<string, unknown>;
31
+ sendDataModel?: boolean;
32
+ };
33
+ }
34
+ export interface A2uiUpdateComponentsMessage {
35
+ version: typeof A2UI_VERSION;
36
+ updateComponents: {
37
+ surfaceId: string;
38
+ components: A2uiComponent[];
39
+ };
40
+ }
41
+ export interface A2uiUpdateDataModelMessage {
42
+ version: typeof A2UI_VERSION;
43
+ updateDataModel: {
44
+ surfaceId: string;
45
+ path?: string;
46
+ value?: unknown;
47
+ };
48
+ }
49
+ export interface A2uiDeleteSurfaceMessage {
50
+ version: typeof A2UI_VERSION;
51
+ deleteSurface: {
52
+ surfaceId: string;
53
+ };
54
+ }
55
+ /** One server-to-client message. */
56
+ export type A2uiMessage = A2uiCreateSurfaceMessage | A2uiUpdateComponentsMessage | A2uiUpdateDataModelMessage | A2uiDeleteSurfaceMessage;
57
+ /** Why a conversion failed. */
58
+ export type A2uiIssueCode = 'schema' | 'surface' | 'missing-root' | 'missing-child' | 'cycle' | 'shared-child' | 'template-children' | 'binding' | 'reserved-prop' | 'unsafe-key' | 'not-json' | 'invalid-node' | 'limit';
59
+ export interface A2uiDiagnostic {
60
+ code: A2uiIssueCode;
61
+ /** JSONPath-style location, e.g. `$[1].updateComponents.components[2].text`. */
62
+ path: string;
63
+ message: string;
64
+ }
65
+ /** Size guards for untrusted payloads. */
66
+ export interface A2uiLimits {
67
+ /** Most components in one surface (default 1000). */
68
+ maxComponents?: number;
69
+ /** Deepest nesting, the root at depth 1 (default 64). */
70
+ maxDepth?: number;
71
+ }
@@ -0,0 +1,11 @@
1
+ /** The A2UI protocol version this adapter reads and writes. */
2
+ export const A2UI_VERSION = 'v0.9';
3
+ /** The component id A2UI requires as the root of a surface's tree. */
4
+ export const A2UI_ROOT_ID = 'root';
5
+ /**
6
+ * Component a payload uses as its root when the catalog tree has zero or
7
+ * several top-level nodes. `a2uiToCatalogTree` unwraps it.
8
+ */
9
+ export const A2UI_ROOTS_COMPONENT = 'texuli-roots';
10
+ /** Keys of an A2UI component that are structure, not props. */
11
+ export const A2UI_RESERVED_KEYS = ['id', 'component', 'children', 'child'];
@@ -0,0 +1,21 @@
1
+ import type { AddFilePlan, AddOptions, AddResult } from './types.js';
2
+ /**
3
+ * Plan which files to copy for a component. Does not write.
4
+ * Throws never — errors go on the result.
5
+ */
6
+ export declare function planAdd(options: AddOptions): Promise<{
7
+ result: AddResult;
8
+ plans: AddFilePlan[];
9
+ meta?: {
10
+ id: string;
11
+ package: string;
12
+ version: string;
13
+ sourceDir: string;
14
+ cwdReal: string;
15
+ };
16
+ }>;
17
+ /**
18
+ * Copy a styled component into the consumer project with provenance headers.
19
+ * Local filesystem only — no network. Contained writes only.
20
+ */
21
+ export declare function addComponent(options: AddOptions): Promise<AddResult>;
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Core `texuli add` logic: resolve a styled component, refuse primitives,
3
+ * stamp provenance, write into the consumer tree. No network.
4
+ * Destination and source paths are contained (see paths.ts).
5
+ */
6
+ import { existsSync } from 'node:fs';
7
+ import { mkdir, readdir, readFile, realpath, stat, writeFile } from 'node:fs/promises';
8
+ import { dirname, join, relative, resolve, sep } from 'node:path';
9
+ import { assertNoSymlinkOnPath, assertSourceFileInside, resolveContainedOutDir, resolveContainedSourceDir, validateRegistryEntry, } from "./paths.js";
10
+ import { hasProvenance, provenanceHeader, provenanceSource } from "./provenance.js";
11
+ import { getAddable, isPrimitivesPath, listAddableIds } from "./registry.js";
12
+ const DEFAULT_VERSION = '0.0.0';
13
+ async function listFilesRecursive(dir) {
14
+ const out = [];
15
+ const entries = await readdir(dir, { withFileTypes: true });
16
+ for (const entry of entries) {
17
+ const full = join(dir, entry.name);
18
+ if (entry.isDirectory()) {
19
+ if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.git')
20
+ continue;
21
+ out.push(...(await listFilesRecursive(full)));
22
+ }
23
+ else if (entry.isFile()) {
24
+ out.push(full);
25
+ }
26
+ }
27
+ return out;
28
+ }
29
+ function defaultOutRelative(id) {
30
+ return join('src', 'components', 'texuli', id);
31
+ }
32
+ /**
33
+ * Plan which files to copy for a component. Does not write.
34
+ * Throws never — errors go on the result.
35
+ */
36
+ export async function planAdd(options) {
37
+ const id = options.id.trim().toLowerCase();
38
+ const version = options.version ?? DEFAULT_VERSION;
39
+ const dryRun = Boolean(options.dryRun);
40
+ const force = options.force === true;
41
+ const cwdResolved = resolve(options.cwd);
42
+ const empty = (errors, outDir = cwdResolved) => ({
43
+ result: {
44
+ ok: false,
45
+ id,
46
+ package: '',
47
+ outDir,
48
+ files: [],
49
+ errors,
50
+ dryRun,
51
+ },
52
+ plans: [],
53
+ });
54
+ if (!id)
55
+ return empty(['Component id is required.']);
56
+ if (!options.sourceRoot || !existsSync(options.sourceRoot)) {
57
+ return empty([`Source root does not exist: ${options.sourceRoot || '(empty)'}`]);
58
+ }
59
+ if (isPrimitivesPath(options.sourceRoot)) {
60
+ return empty(['Source root must not be the primitives package.']);
61
+ }
62
+ // (1) Contain outDir under cwd.
63
+ const outRelative = options.outDir ?? defaultOutRelative(id);
64
+ const outResolved = resolveContainedOutDir(options.cwd, outRelative);
65
+ if (!outResolved.ok)
66
+ return empty([outResolved.error]);
67
+ const outDir = outResolved.outDir;
68
+ let cwdReal = cwdResolved;
69
+ try {
70
+ cwdReal = await realpath(cwdResolved);
71
+ }
72
+ catch {
73
+ cwdReal = cwdResolved;
74
+ }
75
+ const entry = getAddable(id, options.registry);
76
+ if (!entry) {
77
+ const known = listAddableIds(options.registry).join(', ');
78
+ return empty([
79
+ `Unknown or non-addable component "${id}". Styled components only (never primitives). Known: ${known || '(none)'}.`,
80
+ ], outDir);
81
+ }
82
+ // (3) Validate registry entry (built-in or options.registry).
83
+ const entryCheck = validateRegistryEntry(entry);
84
+ if (!entryCheck.ok)
85
+ return empty([entryCheck.error], outDir);
86
+ if (entry.package === '@texuli/primitives' ||
87
+ entry.package === 'primitives' ||
88
+ isPrimitivesPath(join('packages', entry.package, entry.directory))) {
89
+ return empty([`Refusing to copy primitives package component "${id}".`], outDir);
90
+ }
91
+ const sourceResolved = resolveContainedSourceDir(options.sourceRoot, entry);
92
+ if (!sourceResolved.ok)
93
+ return empty([sourceResolved.error], outDir);
94
+ const { sourceDir } = sourceResolved;
95
+ if (isPrimitivesPath(sourceDir)) {
96
+ return empty([`Refusing to copy primitives package component "${id}".`], outDir);
97
+ }
98
+ if (!existsSync(sourceDir)) {
99
+ return empty([`Source directory missing: ${sourceDir}`], outDir);
100
+ }
101
+ const sourceStat = await stat(sourceDir);
102
+ if (!sourceStat.isDirectory()) {
103
+ return empty([`Source is not a directory: ${sourceDir}`], outDir);
104
+ }
105
+ const files = await listFilesRecursive(sourceDir);
106
+ if (files.length === 0) {
107
+ return empty([`No files found under ${sourceDir}`], outDir);
108
+ }
109
+ // Headers record the source relative to the real root, so a symlinked root
110
+ // (macOS /var -> /private/var) never stamps a parent or machine path.
111
+ const sourceDirRel = relative(await realpath(options.sourceRoot), await realpath(sourceDir))
112
+ .split(sep)
113
+ .join('/');
114
+ const plans = [];
115
+ const fileResults = [];
116
+ const errors = [];
117
+ for (const from of files) {
118
+ if (isPrimitivesPath(from)) {
119
+ return empty([`Refusing path under primitives: ${from}`], outDir);
120
+ }
121
+ // (4) Source file must realpath inside sourceDir.
122
+ const inside = assertSourceFileInside(sourceDir, from);
123
+ if (!inside.ok) {
124
+ return empty([inside.error], outDir);
125
+ }
126
+ const relativePath = relative(sourceDir, from).split(sep).join('/');
127
+ // Relative path must not escape (belt and braces).
128
+ if (relativePath.startsWith('..') ||
129
+ relativePath.includes(`../`) ||
130
+ relativePath.includes('..\\')) {
131
+ return empty([`Source relative path escapes component directory: ${relativePath}`], outDir);
132
+ }
133
+ if (provenanceSource(sourceDirRel, relativePath) === null) {
134
+ return empty([`Provenance source would not be a plain relative path: ${sourceDirRel}/${relativePath}`], outDir);
135
+ }
136
+ const to = resolve(outDir, relativePath);
137
+ // Destination file must stay under outDir / cwd.
138
+ const relToOut = relative(outDir, to);
139
+ if (relToOut.startsWith(`..${sep}`) || relToOut === '..' || isAbsoluteRel(relToOut)) {
140
+ return empty([`Destination file escapes outDir: ${to}`], outDir);
141
+ }
142
+ // (2) No symlink on destination path (even with --force).
143
+ const linkCheck = assertNoSymlinkOnPath(to, cwdReal);
144
+ if (!linkCheck.ok) {
145
+ return empty([linkCheck.error], outDir);
146
+ }
147
+ const buf = await readFile(from);
148
+ plans.push({
149
+ relativePath,
150
+ from: inside.realFile,
151
+ to,
152
+ sourceBytes: buf.byteLength,
153
+ });
154
+ const exists = existsSync(to);
155
+ if (exists && !force) {
156
+ fileResults.push({ path: to, action: 'skip', bytes: buf.byteLength });
157
+ errors.push(`Refusing to overwrite existing file (use --force): ${to}`);
158
+ continue;
159
+ }
160
+ fileResults.push({
161
+ path: to,
162
+ action: dryRun ? 'would-write' : 'write',
163
+ bytes: buf.byteLength,
164
+ });
165
+ }
166
+ const ok = errors.length === 0;
167
+ return {
168
+ result: {
169
+ ok,
170
+ id: entry.id,
171
+ package: entry.package,
172
+ outDir,
173
+ files: fileResults,
174
+ errors,
175
+ dryRun,
176
+ },
177
+ plans: ok ? plans : [],
178
+ meta: ok
179
+ ? {
180
+ id: entry.id,
181
+ package: entry.package,
182
+ version,
183
+ sourceDir: sourceDirRel,
184
+ cwdReal,
185
+ }
186
+ : undefined,
187
+ };
188
+ }
189
+ function isAbsoluteRel(rel) {
190
+ return rel.startsWith('/') || /^[A-Za-z]:[\\/]/.test(rel);
191
+ }
192
+ /**
193
+ * Copy a styled component into the consumer project with provenance headers.
194
+ * Local filesystem only — no network. Contained writes only.
195
+ */
196
+ export async function addComponent(options) {
197
+ const { result, plans, meta } = await planAdd(options);
198
+ if (!result.ok || !meta || result.dryRun)
199
+ return result;
200
+ for (const plan of plans) {
201
+ // Re-check symlink containment immediately before write (TOCTOU).
202
+ const linkCheck = assertNoSymlinkOnPath(plan.to, meta.cwdReal);
203
+ if (!linkCheck.ok) {
204
+ return {
205
+ ...result,
206
+ ok: false,
207
+ errors: [...result.errors, linkCheck.error],
208
+ files: result.files.map((file) => file.path === plan.to ? { ...file, action: 'skip' } : file),
209
+ };
210
+ }
211
+ const raw = await readFile(plan.from, 'utf8');
212
+ // planAdd checked every path; null here would be a bug, so stamp nothing unsafe.
213
+ const source = provenanceSource(meta.sourceDir, plan.relativePath);
214
+ if (source === null) {
215
+ return {
216
+ ...result,
217
+ ok: false,
218
+ errors: [...result.errors, `Unsafe provenance source: ${plan.relativePath}`],
219
+ };
220
+ }
221
+ const header = hasProvenance(raw)
222
+ ? ''
223
+ : provenanceHeader({ id: meta.id, package: meta.package, version: meta.version, source }, plan.relativePath);
224
+ const body = header + raw;
225
+ await mkdir(dirname(plan.to), { recursive: true });
226
+ // After mkdir, parents must still be free of symlinks.
227
+ const afterMkdir = assertNoSymlinkOnPath(plan.to, meta.cwdReal);
228
+ if (!afterMkdir.ok) {
229
+ return {
230
+ ...result,
231
+ ok: false,
232
+ errors: [...result.errors, afterMkdir.error],
233
+ };
234
+ }
235
+ await writeFile(plan.to, body, 'utf8');
236
+ }
237
+ return {
238
+ ...result,
239
+ files: result.files.map((file) => file.action === 'would-write' ? { ...file, action: 'write' } : file),
240
+ };
241
+ }
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ export type AddCliArgs = {
3
+ id: string | null;
4
+ cwd: string;
5
+ sourceRoot: string;
6
+ outDir: string | null;
7
+ version: string | null;
8
+ json: boolean;
9
+ dryRun: boolean;
10
+ force: boolean;
11
+ list: boolean;
12
+ help: boolean;
13
+ };
14
+ /** Parse argv after the process name (and optional `add` verb). */
15
+ export declare function parseAddArgs(argv: readonly string[], defaults?: {
16
+ cwd?: string;
17
+ sourceRoot?: string;
18
+ }): AddCliArgs;
19
+ /** Run the add CLI. Returns a process exit code. */
20
+ export declare function runAddCli(argv: readonly string[], defaults?: {
21
+ cwd?: string;
22
+ sourceRoot?: string;
23
+ }): Promise<number>;
24
+ /** True when this module is the process entry (tsx / node cli.ts). */
25
+ export declare function isMainModule(metaUrl: string): boolean;
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env node
2
+ /// <reference types="node" />
3
+ /**
4
+ * `texuli add <component> [options]`
5
+ *
6
+ * Copies styled component source into the consumer project with a provenance
7
+ * header. Never copies primitives. No network.
8
+ *
9
+ * pnpm exec tsx packages/tools/src/cli-add/cli.ts button --cwd <app> --source-root <repo>
10
+ * … --json --dry-run --force --out src/ui/button
11
+ */
12
+ import { fileURLToPath } from 'node:url';
13
+ import { addComponent } from "./add.js";
14
+ import { listAddableIds } from "./registry.js";
15
+ const USAGE = `Usage: texuli add <component> [options]
16
+
17
+ Copy styled Texuli component source into the current project (never primitives).
18
+
19
+ Options:
20
+ --cwd <dir> Consumer project root (default: process.cwd())
21
+ --source-root <dir> Monorepo / fixture root that holds packages/ (required in tests; default: cwd)
22
+ --out <dir> Destination relative to cwd (default: src/components/texuli/<id>)
23
+ --version <semver> Version stamped in the provenance header (default: 0.0.0)
24
+ --force Overwrite existing files
25
+ --dry-run Plan only; write nothing
26
+ --json Machine-readable result on stdout
27
+ --list Print addable component ids and exit
28
+ --help Show this help
29
+ `;
30
+ /** Parse argv after the process name (and optional `add` verb). */
31
+ export function parseAddArgs(argv, defaults) {
32
+ const cwdDefault = defaults?.cwd ?? process.cwd();
33
+ const sourceDefault = defaults?.sourceRoot ?? cwdDefault;
34
+ const args = {
35
+ id: null,
36
+ cwd: cwdDefault,
37
+ sourceRoot: sourceDefault,
38
+ outDir: null,
39
+ version: null,
40
+ json: false,
41
+ dryRun: false,
42
+ force: false,
43
+ list: false,
44
+ help: false,
45
+ };
46
+ const rest = [...argv];
47
+ // Drop a leading `add` verb when the bin routes all subcommands here.
48
+ if (rest[0] === 'add')
49
+ rest.shift();
50
+ for (let i = 0; i < rest.length; i += 1) {
51
+ const arg = rest[i];
52
+ if (arg === undefined)
53
+ break;
54
+ const next = () => {
55
+ const value = rest[i + 1];
56
+ if (value === undefined || value.startsWith('-')) {
57
+ throw new Error(`${arg} needs a value`);
58
+ }
59
+ i += 1;
60
+ return value;
61
+ };
62
+ if (arg === '--help' || arg === '-h')
63
+ args.help = true;
64
+ else if (arg === '--json')
65
+ args.json = true;
66
+ else if (arg === '--dry-run')
67
+ args.dryRun = true;
68
+ else if (arg === '--force')
69
+ args.force = true;
70
+ else if (arg === '--list')
71
+ args.list = true;
72
+ else if (arg === '--cwd')
73
+ args.cwd = next();
74
+ else if (arg === '--source-root')
75
+ args.sourceRoot = next();
76
+ else if (arg === '--out')
77
+ args.outDir = next();
78
+ else if (arg === '--version')
79
+ args.version = next();
80
+ else if (arg.startsWith('-'))
81
+ throw new Error(`Unknown option: ${arg}`);
82
+ else if (args.id === null)
83
+ args.id = arg;
84
+ else
85
+ throw new Error(`Unexpected argument: ${arg}`);
86
+ }
87
+ return args;
88
+ }
89
+ function printHuman(result) {
90
+ if (!result.ok) {
91
+ for (const error of result.errors)
92
+ console.error(error);
93
+ return;
94
+ }
95
+ const verb = result.dryRun ? 'would add' : 'added';
96
+ console.log(`${verb} ${result.id} (${result.package}) → ${result.outDir}`);
97
+ for (const file of result.files) {
98
+ console.log(` ${file.action.padEnd(12)} ${file.path} (${file.bytes} B)`);
99
+ }
100
+ }
101
+ /** Run the add CLI. Returns a process exit code. */
102
+ export async function runAddCli(argv, defaults) {
103
+ let args;
104
+ try {
105
+ args = parseAddArgs(argv, defaults);
106
+ }
107
+ catch (error) {
108
+ console.error(error instanceof Error ? error.message : String(error));
109
+ console.error(USAGE);
110
+ return 1;
111
+ }
112
+ if (args.help) {
113
+ console.log(USAGE);
114
+ return 0;
115
+ }
116
+ if (args.list) {
117
+ const ids = listAddableIds();
118
+ console.log(args.json ? JSON.stringify({ ids }) : ids.join('\n'));
119
+ return 0;
120
+ }
121
+ if (!args.id) {
122
+ console.error('Component id is required.');
123
+ console.error(USAGE);
124
+ return 1;
125
+ }
126
+ const result = await addComponent({
127
+ id: args.id,
128
+ cwd: args.cwd,
129
+ sourceRoot: args.sourceRoot,
130
+ ...(args.outDir ? { outDir: args.outDir } : {}),
131
+ ...(args.version ? { version: args.version } : {}),
132
+ dryRun: args.dryRun,
133
+ force: args.force,
134
+ });
135
+ if (args.json)
136
+ console.log(JSON.stringify(result));
137
+ else
138
+ printHuman(result);
139
+ return result.ok ? 0 : 1;
140
+ }
141
+ /** True when this module is the process entry (tsx / node cli.ts). */
142
+ export function isMainModule(metaUrl) {
143
+ const entry = process.argv[1];
144
+ if (!entry)
145
+ return false;
146
+ try {
147
+ return fileURLToPath(metaUrl) === entry || fileURLToPath(metaUrl) === fileURLToPath(entry);
148
+ }
149
+ catch {
150
+ return metaUrl.endsWith(entry.replace(/\\/g, '/'));
151
+ }
152
+ }
153
+ if (isMainModule(import.meta.url)) {
154
+ runAddCli(process.argv.slice(2)).then((code) => {
155
+ process.exitCode = code;
156
+ });
157
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * `@texuli/tools/cli-add` — `texuli add <component>`
3
+ *
4
+ * Copies styled component source into a consumer project with a provenance
5
+ * header. Never copies primitives. Local filesystem only (no network).
6
+ */
7
+ export { addComponent, planAdd } from './add.js';
8
+ export { type AddCliArgs, isMainModule, parseAddArgs, runAddCli } from './cli.js';
9
+ export { assertNoSymlinkOnPath, assertSourceFileInside, escapesRoot, resolveContainedOutDir, resolveContainedSourceDir, validateRegistryEntry, } from './paths.js';
10
+ export { hasProvenance, isPlainSourcePath, type ProvenanceMeta, provenanceBlockComment, provenanceBody, provenanceHeader, provenanceHtmlComment, provenanceSource, } from './provenance.js';
11
+ export { DEFAULT_ADD_REGISTRY, getAddable, isPrimitivesPath, listAddableIds, } from './registry.js';
12
+ export type { AddableComponent, AddFilePlan, AddOptions, AddResult, } from './types.js';
@@ -0,0 +1,11 @@
1
+ /**
2
+ * `@texuli/tools/cli-add` — `texuli add <component>`
3
+ *
4
+ * Copies styled component source into a consumer project with a provenance
5
+ * header. Never copies primitives. Local filesystem only (no network).
6
+ */
7
+ export { addComponent, planAdd } from "./add.js";
8
+ export { isMainModule, parseAddArgs, runAddCli } from "./cli.js";
9
+ export { assertNoSymlinkOnPath, assertSourceFileInside, escapesRoot, resolveContainedOutDir, resolveContainedSourceDir, validateRegistryEntry, } from "./paths.js";
10
+ export { hasProvenance, isPlainSourcePath, provenanceBlockComment, provenanceBody, provenanceHeader, provenanceHtmlComment, provenanceSource, } from "./provenance.js";
11
+ export { DEFAULT_ADD_REGISTRY, getAddable, isPrimitivesPath, listAddableIds, } from "./registry.js";
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Re-export shared path containment (implementation lives in `../internal/paths.ts`).
3
+ */
4
+ export { assertNoSymlinkOnPath, assertSourceFileInside, escapesRoot, resolveContainedOutDir, resolveContainedSourceDir, validateRegistryEntry, } from '../internal/paths.js';
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Re-export shared path containment (implementation lives in `../internal/paths.ts`).
3
+ */
4
+ export { assertNoSymlinkOnPath, assertSourceFileInside, escapesRoot, resolveContainedOutDir, resolveContainedSourceDir, validateRegistryEntry, } from "../internal/paths.js";