@smallpen/cli 0.1.0-alpha.3 → 0.1.0-alpha.8

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,219 @@
1
+ import { SmallPenError } from "@smallpen/core";
2
+
3
+ // Public discovery fields are independent of the stored object shape.
4
+ export const DISCOVERY_ITEM_FIELDS = Object.freeze([
5
+ "id",
6
+ "name",
7
+ "path",
8
+ "type",
9
+ "kind",
10
+ "category",
11
+ "packageId",
12
+ "source",
13
+ "revision",
14
+ "screenId",
15
+ "presentationId",
16
+ "platform",
17
+ "viewport",
18
+ "variantCount",
19
+ "presentationCount",
20
+ "nodeCount",
21
+ ]);
22
+
23
+ export const DISCOVERY_OUTPUT = Object.freeze({
24
+ encoding: "compact-json",
25
+ itemFields: DISCOVERY_ITEM_FIELDS,
26
+ conditionalItemFields: {
27
+ visibility:
28
+ "Only private: reuse is limited to the owning package; omission means no restriction.",
29
+ deprecated:
30
+ "Only true: avoid new reuse; omission means no deprecation warning.",
31
+ replacement:
32
+ "Only a deprecated asset's replacement {packageId,assetId} when configured.",
33
+ },
34
+ definitions:
35
+ "Descriptions, editable definitions and node trees require a target read or explicit --full.",
36
+ compatibility:
37
+ "Default items use this public allowlist, never stored-object passthrough. Consumers must allow omitted optional fields and use contractRevision to refresh schemas.",
38
+ });
39
+
40
+ export function reuseStatus(item) {
41
+ return {
42
+ ...(item.visibility === "private" ? { visibility: "private" } : {}),
43
+ ...(item.deprecated === true ? { deprecated: true } : {}),
44
+ ...(item.deprecated === true && item.replacement
45
+ ? {
46
+ replacement: {
47
+ packageId: item.replacement.packageId,
48
+ assetId: item.replacement.assetId,
49
+ },
50
+ }
51
+ : {}),
52
+ };
53
+ }
54
+
55
+ export function discoverySummary(item, status = item) {
56
+ const summary = itemSummary(item);
57
+ return {
58
+ ...Object.fromEntries(
59
+ DISCOVERY_ITEM_FIELDS.filter(
60
+ (field) => summary[field] !== undefined && summary[field] !== "",
61
+ ).map((field) => [
62
+ field,
63
+ field === "viewport"
64
+ ? Object.fromEntries(
65
+ ["width", "height", "scale"]
66
+ .filter((key) => summary.viewport[key] !== undefined)
67
+ .map((key) => [key, summary.viewport[key]]),
68
+ )
69
+ : summary[field],
70
+ ]),
71
+ ),
72
+ ...reuseStatus(status),
73
+ };
74
+ }
75
+
76
+ export function pageItems(items, { limit = 20, offset = 0 } = {}) {
77
+ return {
78
+ items: items.slice(offset, offset + limit),
79
+ page: {
80
+ hasMore: offset + limit < items.length,
81
+ limit,
82
+ offset,
83
+ total: items.length,
84
+ },
85
+ };
86
+ }
87
+
88
+ // Discovery returns identities and counts. Definitions belong to target reads.
89
+ export function itemSummary(item) {
90
+ const fields = [
91
+ "id",
92
+ "name",
93
+ "path",
94
+ "type",
95
+ "kind",
96
+ "category",
97
+ "description",
98
+ "packageId",
99
+ "source",
100
+ "revision",
101
+ "rootId",
102
+ "platform",
103
+ "viewport",
104
+ "screenId",
105
+ "presentationId",
106
+ "mainNodeId",
107
+ "basePresentationId",
108
+ ];
109
+ return {
110
+ ...Object.fromEntries(
111
+ fields
112
+ .filter((field) => item[field] !== undefined && item[field] !== "")
113
+ .map((field) => [field, item[field]]),
114
+ ),
115
+ ...reuseStatus(item),
116
+ ...(item.variants ? { variantCount: item.variants.length } : {}),
117
+ ...(item.presentations
118
+ ? { presentationCount: item.presentations.length }
119
+ : {}),
120
+ ...(item.nodes ? { nodeCount: Object.keys(item.nodes).length } : {}),
121
+ };
122
+ }
123
+
124
+ export function packageSummary(snapshot) {
125
+ const screens = snapshot.manifest.entries.screens.map(
126
+ (entry) => snapshot.entries[entry],
127
+ );
128
+ const presentations = screens.flatMap((screen) => screen.presentations);
129
+ return {
130
+ packageId: snapshot.manifest.packageId,
131
+ name: snapshot.manifest.name,
132
+ role: snapshot.manifest.role,
133
+ formatVersion: snapshot.manifest.formatVersion,
134
+ defaultScreenId: snapshot.manifest.defaultScreenId,
135
+ revision: snapshot.revision,
136
+ counts: {
137
+ components:
138
+ snapshot.domain.componentSets.size +
139
+ snapshot.domain.locatedComponents.size,
140
+ contextAxes: snapshot.domain.contextAxes.size,
141
+ contextProfiles: snapshot.domain.contextProfiles.size,
142
+ flows: snapshot.domain.flows.size,
143
+ nodes: presentations.reduce(
144
+ (total, presentation) => total + Object.keys(presentation.nodes).length,
145
+ 0,
146
+ ),
147
+ presentations: presentations.length,
148
+ requirements: snapshot.domain.requirements.size,
149
+ scenarios: snapshot.domain.scenarios.size,
150
+ screens: screens.length,
151
+ // Token names; one Token has a value per theme option it sets.
152
+ tokens: new Set([...snapshot.domain.tokens.values()].map((token) => token.path)).size,
153
+ tokenValues: snapshot.domain.tokens.size,
154
+ },
155
+ };
156
+ }
157
+
158
+ function missing(kind, id) {
159
+ throw new SmallPenError("missing_read_target", `Unknown ${kind}: ${id}`, {
160
+ id,
161
+ kind,
162
+ });
163
+ }
164
+
165
+
166
+ // A semantic read keeps one flat node list, with child IDs instead of subtrees.
167
+ export function semanticNodes(tree) {
168
+ const nodes = [];
169
+ const visit = (node) => {
170
+ if (!node) return;
171
+ nodes.push({
172
+ ...node,
173
+ children: (node.children ?? []).map((child) => child.id),
174
+ });
175
+ for (const child of node.children ?? []) visit(child);
176
+ };
177
+ visit(tree.root);
178
+ return nodes;
179
+ }
180
+
181
+ export function viewSummary(
182
+ read,
183
+ { nodeId, full = false, ...pagination } = {},
184
+ ) {
185
+ if (typeof read.result === "string" || read.format === "screenshot")
186
+ return read;
187
+ const projection = read.result;
188
+ const nodes =
189
+ read.format === "semantic"
190
+ ? semanticNodes(projection)
191
+ : Object.values(projection.nodes);
192
+ const selected = nodeId ? nodes.filter((node) => node.id === nodeId) : nodes;
193
+ if (nodeId && selected.length === 0) missing("projected node", nodeId);
194
+ const { items, page } = pageItems(
195
+ selected,
196
+ nodeId
197
+ ? { limit: 1 }
198
+ : {
199
+ ...pagination,
200
+ limit: pagination.limit ?? (full ? selected.length : 20),
201
+ },
202
+ );
203
+ const result =
204
+ read.format === "semantic"
205
+ ? { rootId: projection.root?.id, nodes: items, page }
206
+ : {
207
+ context: projection.context,
208
+ diagnostics: projection.diagnostics,
209
+ fallbackUsed: projection.fallbackUsed,
210
+ nodes: Object.fromEntries(items.map((node) => [node.id, node])),
211
+ page,
212
+ presentation: itemSummary(projection.presentation ?? {}),
213
+ rootId: projection.rootId,
214
+ screen: itemSummary(projection.screen ?? {}),
215
+ };
216
+ const { discovery, ...details } = read;
217
+ return { ...details, ...(!nodeId ? { discovery } : {}), result };
218
+ }
219
+
@@ -0,0 +1,232 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import {
3
+ lstatSync,
4
+ mkdirSync,
5
+ readdirSync,
6
+ realpathSync,
7
+ renameSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import { tmpdir, userInfo } from "node:os";
12
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
13
+ import { SmallPenError } from "@smallpen/core";
14
+ import { secureWindowsDirectories } from "./windows-artifacts.mjs";
15
+
16
+ export const OUTPUT_LIMIT_BYTES = 8192;
17
+ export const RESULT_RETENTION_MS = 30 * 60 * 1000;
18
+ const uuid = "[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}";
19
+ const role =
20
+ "(?:result|render(?:-matrix-\\d{2})?|outline|wireframe|text|undo|changes|evidence|matrix|validation|flow-connections|flow-verification)";
21
+ const managedName = new RegExp(
22
+ `^(smallpen-\\d{13}-${uuid})-(${role}|owner-(\\d+))\\.(?:png|json|txt)(?:\\.smallpen-(\\d+)-${uuid}\\.tmp)?$`,
23
+ );
24
+ const artifactName = new RegExp(`^${role}\\.(?:png|json|txt)$`);
25
+ let prefix = `smallpen-${Date.now()}-${randomUUID()}`;
26
+ let activeMarker;
27
+ let windowsSecured = false;
28
+
29
+ function directories() {
30
+ const owner = process.getuid
31
+ ? `-${process.getuid()}`
32
+ : `-${createHash("sha256").update(JSON.stringify(userInfo())).digest("hex").slice(0, 16)}`;
33
+ const root = join(tmpdir(), `smallpen-cli${owner}`);
34
+ return [root, join(root, "results")];
35
+ }
36
+ function assertPrivate(path) {
37
+ const stat = lstatSync(path);
38
+ if (
39
+ !stat.isDirectory() ||
40
+ (process.getuid &&
41
+ (stat.uid !== process.getuid() || (stat.mode & 0o077) !== 0))
42
+ ) {
43
+ throw new SmallPenError(
44
+ "invalid_artifact_directory",
45
+ "CLI temporary directory must be an owned private directory",
46
+ { path },
47
+ );
48
+ }
49
+ }
50
+ export function artifactDirectory({ create = false, validate = false } = {}) {
51
+ const paths = directories();
52
+ const windowsItems = [];
53
+ for (const path of create || validate ? paths : []) {
54
+ let created = false;
55
+ try {
56
+ if (create) {
57
+ try {
58
+ mkdirSync(path, { mode: 0o700 });
59
+ created = true;
60
+ } catch (error) {
61
+ if (error.code !== "EEXIST") throw error;
62
+ }
63
+ }
64
+ assertPrivate(path);
65
+ windowsItems.push({ path, created });
66
+ } catch (error) {
67
+ if (
68
+ error instanceof SmallPenError ||
69
+ (!create && error.code === "ENOENT")
70
+ )
71
+ throw error;
72
+ throw new SmallPenError(
73
+ "artifact_directory_unavailable",
74
+ "CLI temporary directory is not writable or accessible",
75
+ { path, reason: error.code },
76
+ );
77
+ }
78
+ }
79
+ if (process.platform === "win32" && windowsItems.length && !windowsSecured) {
80
+ secureWindowsDirectories(windowsItems);
81
+ windowsSecured = true;
82
+ }
83
+ return paths.at(-1);
84
+ }
85
+ function realDestination(path) {
86
+ let ancestor = path;
87
+ while (true) {
88
+ try {
89
+ return join(realpathSync(ancestor), relative(ancestor, path));
90
+ } catch (error) {
91
+ if (
92
+ !["ENOENT", "ENOTDIR"].includes(error.code) ||
93
+ dirname(ancestor) === ancestor
94
+ )
95
+ throw error;
96
+ ancestor = dirname(ancestor);
97
+ }
98
+ }
99
+ }
100
+ function ownerAlive(pid) {
101
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
102
+ try {
103
+ process.kill(pid, 0);
104
+ return true;
105
+ } catch (error) {
106
+ return error.code !== "ESRCH";
107
+ }
108
+ }
109
+ function cleanupExpired(directory) {
110
+ const entries = readdirSync(directory).flatMap((name) => {
111
+ const match = name.match(managedName);
112
+ if (!match) return [];
113
+ const path = join(directory, name);
114
+ try {
115
+ const stat = lstatSync(path);
116
+ if (!stat.isFile() && !stat.isSymbolicLink()) return [];
117
+ return [{ path, match, stat }];
118
+ } catch (error) {
119
+ if (error.code === "ENOENT") return [];
120
+ throw error;
121
+ }
122
+ });
123
+ const active = new Set(
124
+ entries
125
+ .filter(({ match }) => match[3] && ownerAlive(Number(match[3])))
126
+ .map(({ match }) => match[1]),
127
+ );
128
+ const cutoff = Date.now() - RESULT_RETENTION_MS;
129
+ for (const { path, match, stat } of entries) {
130
+ if (
131
+ stat.mtimeMs >= cutoff ||
132
+ active.has(match[1]) ||
133
+ (match[4] && ownerAlive(Number(match[4])))
134
+ )
135
+ continue;
136
+ try {
137
+ rmSync(path, { force: true });
138
+ } catch (error) {
139
+ if (error.code !== "ENOENT")
140
+ throw new SmallPenError(
141
+ "artifact_cleanup_failed",
142
+ "An expired CLI temporary file could not be removed",
143
+ { path, reason: error.code },
144
+ );
145
+ }
146
+ }
147
+ }
148
+
149
+ // Every invocation has unique names in one folder. Only expired recognized CLI
150
+ // files are cleaned; caller-owned paths and unrelated files are never removed.
151
+ export function beginArtifacts(_command, args = []) {
152
+ prefix = `smallpen-${Date.now()}-${randomUUID()}`;
153
+ const root = realDestination(directories()[0]);
154
+ for (const option of ["--output", "--inverse-out"]) {
155
+ const index = args.indexOf(option);
156
+ if (index < 0 || !args[index + 1] || args[index + 1].startsWith("--"))
157
+ continue;
158
+ const path = resolve(args[index + 1]);
159
+ const child = relative(root, realDestination(path));
160
+ if (
161
+ !child ||
162
+ (!isAbsolute(child) && child !== ".." && !child.startsWith(`..${sep}`))
163
+ ) {
164
+ throw new SmallPenError(
165
+ "reserved_output_path",
166
+ "Choose a retained output path outside the CLI-managed temporary tree",
167
+ { option, path },
168
+ );
169
+ }
170
+ }
171
+ let directory;
172
+ try {
173
+ directory = artifactDirectory({ validate: true });
174
+ } catch (error) {
175
+ if (error.code === "ENOENT") return;
176
+ throw error;
177
+ }
178
+ cleanupExpired(directory);
179
+ }
180
+ export function artifactPath(kind, extension) {
181
+ if (!artifactName.test(`${kind}.${extension}`))
182
+ throw new Error(`Unknown CLI artifact: ${kind}.${extension}`);
183
+ const directory = artifactDirectory({ create: true });
184
+ if (!activeMarker) {
185
+ activeMarker = join(directory, `${prefix}-owner-${process.pid}.json`);
186
+ writeFileSync(activeMarker, "", { mode: 0o600, flag: "wx" });
187
+ }
188
+ return join(directory, `${prefix}-${kind}.${extension}`);
189
+ }
190
+ export function releaseArtifacts() {
191
+ if (!activeMarker) return;
192
+ try {
193
+ rmSync(activeMarker, { force: true });
194
+ } catch {}
195
+ activeMarker = undefined;
196
+ }
197
+ process.once("exit", releaseArtifacts);
198
+ export function saveResult(bytes, extension = "json") {
199
+ const path = artifactPath("result", extension);
200
+ const temporary = `${path}.smallpen-${process.pid}-${randomUUID()}.tmp`;
201
+ try {
202
+ writeFileSync(temporary, bytes, { mode: 0o600, flag: "wx" });
203
+ renameSync(temporary, path);
204
+ } catch (error) {
205
+ rmSync(temporary, { force: true });
206
+ throw new SmallPenError(
207
+ "result_output_failed",
208
+ "Complete result could not be saved to the CLI temporary directory",
209
+ { path, reason: error.code },
210
+ );
211
+ }
212
+ return {
213
+ path,
214
+ format: extension,
215
+ bytes: Buffer.byteLength(bytes),
216
+ sha256: createHash("sha256").update(bytes).digest("hex"),
217
+ expiresAt: new Date(Date.now() + RESULT_RETENTION_MS).toISOString(),
218
+ };
219
+ }
220
+ export function printText(text) {
221
+ if (
222
+ Buffer.byteLength(text) <= OUTPUT_LIMIT_BYTES ||
223
+ process.argv.includes("--stdout")
224
+ ) {
225
+ process.stdout.write(text);
226
+ return;
227
+ }
228
+ const file = saveResult(text, "txt");
229
+ process.stdout.write(
230
+ `Complete text: ${file.path}\nBytes: ${file.bytes}; SHA-256: ${file.sha256}\nTemporary result: 30 minutes. Read/search it with your usual file tools, or request --stdout.\n`,
231
+ );
232
+ }
@@ -0,0 +1,73 @@
1
+ import { canonicalJSON, listEffectiveTokens } from "@smallpen/core";
2
+
3
+ // Only this batch's new definitions are compared. Same-name Tokens in other
4
+ // options are intentional theme values, not duplicate-token suggestions.
5
+ export function definitionReuseAdvice(before, batch, options) {
6
+ if (
7
+ !batch.operations.some((operation) =>
8
+ ["put-set-token", "put-component-set"].includes(operation.type),
9
+ )
10
+ )
11
+ return [];
12
+ const items = [];
13
+ const tokens = listEffectiveTokens(before, options);
14
+ const components = [before, options.foundation]
15
+ .filter(Boolean)
16
+ .flatMap((snapshot) =>
17
+ [...snapshot.domain.componentSets.values()].map((component) => ({
18
+ ...component,
19
+ packageId: snapshot.manifest.packageId,
20
+ })),
21
+ );
22
+ for (const [operationIndex, operation] of batch.operations.entries()) {
23
+ if (
24
+ operation.type === "put-set-token" &&
25
+ !before.domain.tokens.has(operation.token.id)
26
+ ) {
27
+ const token = operation.token;
28
+ const similar = tokens.filter(
29
+ (item) =>
30
+ item.token.path !== token.name &&
31
+ item.token.type === token.type &&
32
+ canonicalJSON(item.value) === canonicalJSON(token.value),
33
+ );
34
+ if (similar.length)
35
+ items.push({
36
+ code: "existing_token_value",
37
+ operationIndex,
38
+ tokenId: token.id,
39
+ message:
40
+ "An existing Token has this type and value; consider reuse or a semantic alias",
41
+ candidates: similar
42
+ .slice(0, 3)
43
+ .map(({ target, token }) => ({ target, path: token.path })),
44
+ });
45
+ }
46
+ if (
47
+ operation.type === "put-component-set" &&
48
+ !before.domain.componentSets.has(operation.componentSet.id)
49
+ ) {
50
+ const component = operation.componentSet;
51
+ const normalize = (name) => name.toLowerCase().replace(/[\s_-]+/g, "");
52
+ const name = normalize(component.name);
53
+ const similar = components.filter(
54
+ (candidate) =>
55
+ candidate.id !== component.id &&
56
+ (normalize(candidate.name).includes(name) ||
57
+ name.includes(normalize(candidate.name))),
58
+ );
59
+ if (similar.length)
60
+ items.push({
61
+ code: "similar_component",
62
+ operationIndex,
63
+ componentId: component.id,
64
+ message:
65
+ "A similarly named component exists; inspect it before adding another",
66
+ candidates: similar
67
+ .slice(0, 3)
68
+ .map(({ id, name, packageId }) => ({ id, name, packageId })),
69
+ });
70
+ }
71
+ }
72
+ return items;
73
+ }