lineadraw 0.1.0 → 0.3.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.
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+ // The `lineadraw` CLI — marketplace tooling for Linea block/command repos.
3
+ // lineadraw manifest [--dir <path>] generate .lineadraw/marketplace.json
4
+ // lineadraw check [--dir <path>] validate + fail if the manifest is stale
5
+ // lineadraw init [--dir <path>] add the marketplace structure here
6
+ // See docs/marketplace-devx.md in the linea repo for the design.
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { dirname, join, resolve } from "node:path";
9
+ import {
10
+ buildManifest,
11
+ manifestJson,
12
+ MANIFEST_PATH,
13
+ verifyWithSdk,
14
+ } from "../lib/manifest.mjs";
15
+ import { init } from "../lib/init.mjs";
16
+
17
+ const USAGE = `Usage: lineadraw <manifest|check|init> [--dir <path>]
18
+
19
+ manifest Validate all modules and write ${MANIFEST_PATH}
20
+ check Validate without writing; fail if the committed manifest is stale
21
+ init Add the marketplace structure to the current npm project
22
+
23
+ With @lineadraw/sdk installed (devDependency), manifest and check also
24
+ dry-run every block. The manifest content is identical either way.`;
25
+
26
+ const args = process.argv.slice(2);
27
+ const command = args[0];
28
+ const dirFlag = args.indexOf("--dir");
29
+ const dir = resolve(dirFlag === -1 ? "." : (args[dirFlag + 1] ?? "."));
30
+
31
+ const fail = (message) => {
32
+ console.error(message);
33
+ process.exit(1);
34
+ };
35
+
36
+ const reportProblems = (problems) => {
37
+ for (const p of problems)
38
+ console.error(`REJECTED ${p.file}: ${p.problems.join("; ")}`);
39
+ };
40
+
41
+ /** Validate everything (light + optional sdk dry-run); exits on failure. */
42
+ const validatedManifest = async () => {
43
+ const { manifest, sources, problems } = buildManifest(dir);
44
+ const sdkResult = await verifyWithSdk(dir, manifest, sources);
45
+ const all = [...problems, ...sdkResult.problems];
46
+ if (all.length > 0) {
47
+ reportProblems(all);
48
+ fail(`${all.length} problem(s) — nothing written.`);
49
+ }
50
+ if (!sdkResult.ran)
51
+ console.warn(
52
+ "The contents of blocks/commands were not dry-run because @lineadraw/sdk is not installed. Install it as a devDependency to enable this check.",
53
+ );
54
+ return manifest;
55
+ };
56
+
57
+ switch (command) {
58
+ case "manifest": {
59
+ const manifest = await validatedManifest();
60
+ const outPath = join(dir, MANIFEST_PATH);
61
+ mkdirSync(dirname(outPath), { recursive: true });
62
+ writeFileSync(outPath, manifestJson(manifest));
63
+ console.log(`${MANIFEST_PATH}: ${manifest.entries.length} entries`);
64
+ for (const e of manifest.entries)
65
+ console.log(` ${e.kind} ${e.id} v${e.version}`);
66
+ break;
67
+ }
68
+ case "check": {
69
+ const manifest = await validatedManifest();
70
+ const committedPath = join(dir, MANIFEST_PATH);
71
+ const committed = existsSync(committedPath)
72
+ ? readFileSync(committedPath, "utf8")
73
+ : null;
74
+ if (committed !== manifestJson(manifest))
75
+ fail(
76
+ `${MANIFEST_PATH} is ${committed === null ? "missing" : "stale"} — run \`npx lineadraw manifest\` and commit the result.`,
77
+ );
78
+ console.log(
79
+ `OK: ${manifest.entries.length} entries, manifest is current.`,
80
+ );
81
+ break;
82
+ }
83
+ case "init": {
84
+ try {
85
+ const { created, skipped } = init(dir);
86
+ for (const f of created) console.log(`created ${f}`);
87
+ for (const f of skipped) console.log(`kept ${f}`);
88
+ console.log(
89
+ "\nDone. Commit, push, then add the repo in the editor: Library → Marketplace.",
90
+ );
91
+ } catch (e) {
92
+ fail(e instanceof Error ? e.message : String(e));
93
+ }
94
+ break;
95
+ }
96
+ default:
97
+ fail(USAGE);
98
+ }
package/lib/init.mjs ADDED
@@ -0,0 +1,127 @@
1
+ // `lineadraw init` — add the marketplace structure to the CURRENT npm
2
+ // project, whatever it is. Non-destructive: existing files and package.json
3
+ // keys are never overwritten; everything it creates is reported. Ends by
4
+ // generating `.lineadraw/marketplace.json`, so the repo is addable in the
5
+ // editor from the first push.
6
+ import {
7
+ existsSync,
8
+ mkdirSync,
9
+ readFileSync,
10
+ readdirSync,
11
+ writeFileSync,
12
+ } from "node:fs";
13
+ import { dirname, join } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { buildManifest, manifestJson, MANIFEST_PATH } from "./manifest.mjs";
16
+
17
+ const templatesDir = join(
18
+ dirname(fileURLToPath(import.meta.url)),
19
+ "..",
20
+ "templates",
21
+ );
22
+ const template = (name) => readFileSync(join(templatesDir, name), "utf8");
23
+
24
+ /** Lowercase-kebab collection slug from a package name (`@scope/` and
25
+ * illegal characters dropped, per the `@collection/slug` id grammar). */
26
+ export const collectionSlug = (pkgName) => {
27
+ const slug = String(pkgName ?? "")
28
+ .replace(/^@[^/]+\//, "")
29
+ .toLowerCase()
30
+ .replace(/[^a-z0-9-]+/g, "-")
31
+ .replace(/^-+|-+$/g, "")
32
+ .slice(0, 64);
33
+ return slug === "" ? "my-collection" : slug;
34
+ };
35
+
36
+ /** Run init in `dir`. Returns `{ created, skipped }` path lists. */
37
+ export const init = (dir) => {
38
+ const pkgPath = join(dir, "package.json");
39
+ if (!existsSync(pkgPath))
40
+ throw new Error(
41
+ "no package.json here — run `npm init` first (or start from an empty folder with `npm create lineadraw`)",
42
+ );
43
+ const created = [];
44
+ const skipped = [];
45
+ const write = (relPath, content) => {
46
+ const abs = join(dir, relPath);
47
+ if (existsSync(abs)) {
48
+ skipped.push(relPath);
49
+ return;
50
+ }
51
+ mkdirSync(dirname(abs), { recursive: true });
52
+ writeFileSync(abs, content);
53
+ created.push(relPath);
54
+ };
55
+
56
+ // package.json: fill the marketplace config and scripts, never replacing
57
+ // what is already there.
58
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
59
+ let pkgChanged = false;
60
+ if (pkg.lineadraw === undefined) {
61
+ pkg.lineadraw = {
62
+ marketplaceName: pkg.description ?? pkg.name ?? "Marketplace",
63
+ marketplaceOwner:
64
+ typeof pkg.author === "string" ? pkg.author : (pkg.author?.name ?? ""),
65
+ };
66
+ pkgChanged = true;
67
+ }
68
+ pkg.scripts ??= {};
69
+ for (const [script, cmd] of [
70
+ ["manifest", "lineadraw manifest"],
71
+ ["check", "lineadraw check"],
72
+ ]) {
73
+ if (pkg.scripts[script] === undefined) {
74
+ pkg.scripts[script] = cmd;
75
+ pkgChanged = true;
76
+ }
77
+ }
78
+ if (pkgChanged) {
79
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
80
+ created.push("package.json (lineadraw config + scripts merged)");
81
+ }
82
+
83
+ const blocksDir = pkg.lineadraw?.blocks ?? "blocks";
84
+ const commandsDir = pkg.lineadraw?.commands ?? "commands";
85
+ mkdirSync(join(dir, blocksDir), { recursive: true });
86
+ mkdirSync(join(dir, commandsDir), { recursive: true });
87
+
88
+ // A sample block only when the blocks folder is empty — init on an
89
+ // existing collection must not inject content.
90
+ const hasModules = readdirSync(join(dir, blocksDir)).some(
91
+ (f) => f.endsWith(".ts") || f.endsWith(".js"),
92
+ );
93
+ if (!hasModules) {
94
+ const author = pkg.lineadraw?.marketplaceOwner || "Your Name";
95
+ write(
96
+ join(blocksDir, "sample-box.block.ts"),
97
+ template("sample.block.ts")
98
+ .replaceAll("__COLLECTION__", collectionSlug(pkg.name))
99
+ .replaceAll("__AUTHOR__", author),
100
+ );
101
+ }
102
+
103
+ write(".github/workflows/marketplace.yml", template("workflow.yml"));
104
+ write("tsconfig.json", template("tsconfig.json"));
105
+ write(
106
+ "README.md",
107
+ template("README.md").replaceAll(
108
+ "__NAME__",
109
+ pkg.lineadraw?.marketplaceName ?? pkg.name ?? "Marketplace",
110
+ ),
111
+ );
112
+ write(".gitignore", "node_modules/\n");
113
+
114
+ // Generate the catalog last, over whatever modules now exist.
115
+ const { manifest, problems } = buildManifest(dir);
116
+ if (problems.length > 0) {
117
+ const detail = problems
118
+ .map((p) => `${p.file}: ${p.problems.join("; ")}`)
119
+ .join("\n");
120
+ throw new Error(`existing modules failed validation:\n${detail}`);
121
+ }
122
+ mkdirSync(join(dir, dirname(MANIFEST_PATH)), { recursive: true });
123
+ writeFileSync(join(dir, MANIFEST_PATH), manifestJson(manifest));
124
+ created.push(`${MANIFEST_PATH} (${manifest.entries.length} entries)`);
125
+
126
+ return { created, skipped };
127
+ };
@@ -0,0 +1,225 @@
1
+ // Minimal-manifest generator (docs/marketplace-devx.md in the linea repo).
2
+ // Pure filesystem walk — no git: rows carry repo-RELATIVE `source` paths
3
+ // that the editor resolves against the repo the user added, at the ref the
4
+ // manifest itself was fetched from. Deterministic: same content, same
5
+ // bytes, so `check` is a plain diff.
6
+ import { createRequire } from "node:module";
7
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { pathToFileURL } from "node:url";
10
+
11
+ // sucrase is this package's own dependency — the light path needs nothing
12
+ // else installed.
13
+ const require_ = createRequire(import.meta.url);
14
+
15
+ const ID_RE = /^@[a-z0-9-]{1,64}\/[a-z0-9-]{1,64}$/;
16
+ const SEMVER_RE = /^\d+\.\d+\.\d+$/;
17
+
18
+ /** Marketplace config resolved from the repo's package.json. */
19
+ export const readConfig = (repoDir) => {
20
+ const pkgPath = join(repoDir, "package.json");
21
+ if (!existsSync(pkgPath))
22
+ throw new Error(`no package.json in ${repoDir} — run \`lineadraw init\` in an npm project`);
23
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
24
+ const cfg = pkg.lineadraw ?? {};
25
+ return {
26
+ name: cfg.marketplaceName ?? pkg.description ?? pkg.name ?? "Marketplace",
27
+ owner:
28
+ cfg.marketplaceOwner ??
29
+ (typeof pkg.author === "string" ? pkg.author : (pkg.author?.name ?? "")),
30
+ blocksDir: cfg.blocks ?? "blocks",
31
+ commandsDir: cfg.commands ?? "commands",
32
+ };
33
+ };
34
+
35
+ // Same execution model as @lineadraw/core's transpiler: ESM→CJS, run
36
+ // against a require shim. Helpers resolve to no-op function proxies —
37
+ // top-level code only needs the imports to EXIST; draw()/run() never run
38
+ // here (that is the optional sdk dry-run's job).
39
+ const helperProxy = new Proxy({}, { get: () => () => ({}) });
40
+
41
+ const executeModule = (source) => {
42
+ const { transform } = require_("sucrase");
43
+ const code = transform(source, {
44
+ transforms: ["typescript", "imports"],
45
+ }).code;
46
+ const moduleShim = { exports: {} };
47
+ new Function("exports", "require", "module", code)(
48
+ moduleShim.exports,
49
+ (spec) => {
50
+ if (spec === "lineadraw" || spec === "linea")
51
+ return {
52
+ helpers: helperProxy,
53
+ defineBlock: (s) => s,
54
+ defineCommand: (s) => s,
55
+ };
56
+ if (spec === "lineadraw/helpers" || spec === "linea/helpers")
57
+ return helperProxy;
58
+ throw new Error(`illegal import "${spec}"`);
59
+ },
60
+ moduleShim,
61
+ );
62
+ const exported = moduleShim.exports;
63
+ const spec = exported.default;
64
+ return spec !== null && typeof spec === "object" && !Array.isArray(spec)
65
+ ? { ...exported, ...spec }
66
+ : exported;
67
+ };
68
+
69
+ const entryOf = (kind, repoDir, folder, file) => {
70
+ const source = readFileSync(join(repoDir, folder, file), "utf8");
71
+ let mod;
72
+ try {
73
+ mod = executeModule(source);
74
+ } catch (e) {
75
+ return { file, problems: [`does not compile: ${e.message}`] };
76
+ }
77
+ const problems = [];
78
+ if (typeof mod.id !== "string" || !ID_RE.test(mod.id))
79
+ problems.push(`id ${JSON.stringify(mod.id)} is not @collection/slug`);
80
+ if (typeof mod.name !== "string" || mod.name.trim() === "")
81
+ problems.push("missing name");
82
+ if (typeof mod.version !== "string" || !SEMVER_RE.test(mod.version))
83
+ problems.push(`version ${JSON.stringify(mod.version)} is not semver`);
84
+ if (
85
+ kind === "block" &&
86
+ typeof mod.draw !== "function" &&
87
+ typeof mod.main !== "function"
88
+ )
89
+ problems.push("no draw export");
90
+ if (kind === "command" && typeof mod.run !== "function")
91
+ problems.push("no run export");
92
+ if (problems.length > 0) return { file, problems };
93
+ return {
94
+ file,
95
+ source,
96
+ entry: {
97
+ kind,
98
+ id: mod.id,
99
+ name: mod.name,
100
+ version: mod.version,
101
+ ...(typeof mod.description === "string"
102
+ ? { description: mod.description }
103
+ : {}),
104
+ tags: Array.isArray(mod.tags)
105
+ ? mod.tags.filter((t) => typeof t === "string")
106
+ : [],
107
+ authors: Array.isArray(mod.authors)
108
+ ? mod.authors.filter((a) => typeof a === "string")
109
+ : [],
110
+ dependencies:
111
+ kind === "block" && Array.isArray(mod.dependencies)
112
+ ? mod.dependencies.filter((d) => typeof d === "string")
113
+ : [],
114
+ // POSIX-relative, unencoded — the editor percent-encodes segments
115
+ // when it builds the fetch URL.
116
+ source: `${folder}/${file}`,
117
+ },
118
+ };
119
+ };
120
+
121
+ const listModules = (repoDir, folder) =>
122
+ existsSync(join(repoDir, folder))
123
+ ? readdirSync(join(repoDir, folder))
124
+ .filter((f) => f.endsWith(".ts") || f.endsWith(".js"))
125
+ .sort()
126
+ : [];
127
+
128
+ /**
129
+ * Build the manifest for a marketplace repo. Returns
130
+ * `{ manifest, sources, problems }`; `problems` is a list of
131
+ * `{ file, problems }` — a non-empty list means the repo must not publish
132
+ * (callers fail instead of writing a partial catalog). `sources` maps
133
+ * entry id → module source (for the optional sdk dry-run).
134
+ */
135
+ export const buildManifest = (repoDir) => {
136
+ const cfg = readConfig(repoDir);
137
+ const results = [
138
+ ...listModules(repoDir, cfg.blocksDir).map((f) =>
139
+ entryOf("block", repoDir, cfg.blocksDir, f),
140
+ ),
141
+ ...listModules(repoDir, cfg.commandsDir).map((f) =>
142
+ entryOf("command", repoDir, cfg.commandsDir, f),
143
+ ),
144
+ ];
145
+ const problems = results.filter((r) => r.problems).map((r) => ({
146
+ file: r.file,
147
+ problems: r.problems,
148
+ }));
149
+ const entries = results.filter((r) => r.entry).map((r) => r.entry);
150
+
151
+ const dupes = [
152
+ ...new Set(
153
+ entries
154
+ .map((e) => e.id)
155
+ .filter((id, i, all) => all.indexOf(id) !== i),
156
+ ),
157
+ ];
158
+ if (dupes.length > 0)
159
+ problems.push({
160
+ file: "(catalog)",
161
+ problems: [`duplicate ids: ${dupes.join(", ")}`],
162
+ });
163
+
164
+ // The catalog must be dependency-CLOSED: every declared dependency
165
+ // resolves to a block entry in this same manifest.
166
+ const blockIds = new Set(
167
+ entries.filter((e) => e.kind === "block").map((e) => e.id),
168
+ );
169
+ const dangling = entries.flatMap((e) =>
170
+ e.dependencies
171
+ .filter((d) => !blockIds.has(d))
172
+ .map((d) => `${e.id} → ${d}`),
173
+ );
174
+ if (dangling.length > 0)
175
+ problems.push({
176
+ file: "(catalog)",
177
+ problems: [`dangling dependencies: ${dangling.join(", ")}`],
178
+ });
179
+
180
+ const sources = new Map(
181
+ results.filter((r) => r.entry).map((r) => [r.entry.id, r.source]),
182
+ );
183
+ return {
184
+ manifest: {
185
+ name: cfg.name,
186
+ owner: { name: cfg.owner },
187
+ entries,
188
+ },
189
+ sources,
190
+ problems,
191
+ };
192
+ };
193
+
194
+ export const manifestJson = (manifest) =>
195
+ JSON.stringify(manifest, null, 2) + "\n";
196
+
197
+ export const MANIFEST_PATH = ".lineadraw/marketplace.json";
198
+
199
+ /**
200
+ * Optional execution-level verification: dry-run every block through
201
+ * @lineadraw/sdk when the repo has it installed. Returns
202
+ * `{ ran, problems }` — `ran: false` when the sdk (or a new enough one) is
203
+ * absent; validation-only, so manifest CONTENT never depends on it.
204
+ */
205
+ export const verifyWithSdk = async (repoDir, manifest, sources) => {
206
+ let sdk;
207
+ try {
208
+ const repoRequire = createRequire(join(repoDir, "package.json"));
209
+ sdk = await import(
210
+ pathToFileURL(repoRequire.resolve("@lineadraw/sdk")).href
211
+ );
212
+ } catch {
213
+ return { ran: false, problems: [] };
214
+ }
215
+ if (typeof sdk.testBlockDefinition !== "function")
216
+ return { ran: false, problems: [] }; // sdk predates module validation
217
+ const problems = [];
218
+ for (const entry of manifest.entries) {
219
+ if (entry.kind !== "block") continue; // commands are interactive — no dry run
220
+ const result = await sdk.testBlockDefinition(sources.get(entry.id));
221
+ if (!result.ok)
222
+ problems.push({ file: entry.source, problems: [result.error] });
223
+ }
224
+ return { ran: true, problems };
225
+ };