@smallpen/cli 0.1.0-alpha.1 → 0.1.0-alpha.11

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.
@@ -1,5 +1,10 @@
1
1
  // Presentation only: the core warning list and mutation semantics stay unchanged.
2
- export function warningOutput(warnings, detail, { confirmUnmatched = false } = {}) {
2
+ // validate checks Package validity only; say so, so advice on a valid write
3
+ // does not read as a defect that validate failed to report.
4
+ const ADVISORY_NOTE =
5
+ "Design advice only: the write is valid and was not gated. validate does not repeat this advice.";
6
+
7
+ export function warningOutput(warnings, detail, { confirmUnmatched = false, suppressed } = {}) {
3
8
  const rank = (warning) => warning.match === "exact"
4
9
  ? 0
5
10
  : warning.suggestions.length > 0 ? 1 : 2;
@@ -16,6 +21,8 @@ export function warningOutput(warnings, detail, { confirmUnmatched = false } = {
16
21
  total: warnings.length,
17
22
  counts,
18
23
  contextScope: warnings[0]?.contextScope,
24
+ ...(suppressed ? { suppressed } : {}),
25
+ ...(warnings.length > 0 ? { note: ADVISORY_NOTE } : {}),
19
26
  };
20
27
  if (detail === "full") {
21
28
  return {
@@ -32,7 +39,7 @@ export function warningOutput(warnings, detail, { confirmUnmatched = false } = {
32
39
 
33
40
  const groups = new Map();
34
41
  for (const [warningIndex, warning] of warnings.entries()) {
35
- const { nodeId, operationIndex, value, contextScope, ...shared } = warning;
42
+ const { nodeId, element, operationIndex, value, contextScope, ...shared } = warning;
36
43
  const key = JSON.stringify(shared);
37
44
  let group = groups.get(key);
38
45
  if (!group) {
@@ -40,7 +47,7 @@ export function warningOutput(warnings, detail, { confirmUnmatched = false } = {
40
47
  groups.set(key, group);
41
48
  }
42
49
  group.count += 1;
43
- group.locations.push({ warningIndex, operationIndex, nodeId, value });
50
+ group.locations.push({ warningIndex, operationIndex, nodeId, ...(element !== undefined ? { element } : {}), value });
44
51
  }
45
52
  const grouped = [...groups.values()].sort(
46
53
  (left, right) => rank(left) - rank(right),
@@ -0,0 +1,72 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { join } from "node:path";
3
+ import { SmallPenError } from "@smallpen/core";
4
+
5
+ // Use the Windows ACL API: POSIX mkdir modes do not protect Windows files.
6
+ // Paths are JSON on stdin, never interpolated into executable PowerShell code.
7
+ const script = String.raw`
8
+ $ErrorActionPreference = 'Stop'
9
+ [Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false)
10
+ $items = [Console]::In.ReadToEnd() | ConvertFrom-Json
11
+ $sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
12
+ foreach ($item in $items) {
13
+ $path = $item.path
14
+ $attributes = [System.IO.File]::GetAttributes($path)
15
+ if (($attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Reparse point is not a private CLI directory' }
16
+ $existing = [System.IO.Directory]::GetAccessControl($path)
17
+ $owner = $existing.GetOwner([System.Security.Principal.SecurityIdentifier])
18
+ if (-not $item.created -and $owner.Value -ne $sid.Value) { throw 'CLI directory belongs to another Windows user' }
19
+ $rules = $existing.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])
20
+ if ($existing.AreAccessRulesProtected -and $owner.Value -eq $sid.Value -and $rules.Count -eq 1 -and $rules[0].IdentityReference.Value -eq $sid.Value -and $rules[0].AccessControlType -eq [System.Security.AccessControl.AccessControlType]::Allow -and $rules[0].FileSystemRights -eq [System.Security.AccessControl.FileSystemRights]::FullControl -and $rules[0].InheritanceFlags -eq [System.Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit') { continue }
21
+ $security = [System.Security.AccessControl.DirectorySecurity]::new()
22
+ $security.SetOwner($sid)
23
+ $security.SetAccessRuleProtection($true, $false)
24
+ $rule = [System.Security.AccessControl.FileSystemAccessRule]::new($sid, [System.Security.AccessControl.FileSystemRights]::FullControl, [System.Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit', [System.Security.AccessControl.PropagationFlags]::None, [System.Security.AccessControl.AccessControlType]::Allow)
25
+ $security.AddAccessRule($rule)
26
+ [System.IO.Directory]::SetAccessControl($path, $security)
27
+ $actual = [System.IO.Directory]::GetAccessControl($path)
28
+ if ($actual.GetOwner([System.Security.Principal.SecurityIdentifier]).Value -ne $sid.Value) { throw 'CLI ACL owner was not set' }
29
+ if (-not $actual.AreAccessRulesProtected) { throw 'CLI ACL still inherits access rules' }
30
+ foreach ($access in $actual.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) {
31
+ if ($access.AccessControlType -eq [System.Security.AccessControl.AccessControlType]::Allow -and $access.IdentityReference.Value -ne $sid.Value) { throw 'CLI directory grants another user access' }
32
+ }
33
+ }
34
+ `;
35
+
36
+ export function secureWindowsDirectories(items) {
37
+ try {
38
+ execFileSync(
39
+ join(
40
+ process.env.SystemRoot ?? "C:\\Windows",
41
+ "System32",
42
+ "WindowsPowerShell",
43
+ "v1.0",
44
+ "powershell.exe",
45
+ ),
46
+ [
47
+ "-NoLogo",
48
+ "-NoProfile",
49
+ "-NonInteractive",
50
+ "-EncodedCommand",
51
+ Buffer.from(script, "utf16le").toString("base64"),
52
+ ],
53
+ {
54
+ input: JSON.stringify(items),
55
+ timeout: 10000,
56
+ maxBuffer: 65536,
57
+ windowsHide: true,
58
+ stdio: ["pipe", "pipe", "pipe"],
59
+ },
60
+ );
61
+ } catch {
62
+ throw new SmallPenError(
63
+ "artifact_directory_unavailable",
64
+ "Windows could not grant and verify private writable access to the CLI temporary directory",
65
+ {
66
+ path: items.at(-1).path,
67
+ recovery:
68
+ "Use a writable NTFS temporary directory and Windows PowerShell, or choose a retained --output file outside the CLI temporary tree.",
69
+ },
70
+ );
71
+ }
72
+ }
@@ -0,0 +1,55 @@
1
+ export const CLI_RULES = Object.freeze({
2
+ purpose:
3
+ "CLI operation rules. Read the selected command and input schema; pass explicit locators and returned IDs.",
4
+ selection: {
5
+ appStateIndependent: true,
6
+ defaults:
7
+ "No --theme: use each project's group defaults, never the App's active selection. Libraries without stored defaults use the real Default option, else the first option in library order.",
8
+ precedence:
9
+ "Project defaults < explicitly requested Scenario themes < explicit --theme. Override only named groups; unspecified groups keep defaults. Presentation chooses structure and does not implicitly choose a theme.",
10
+ dimensions:
11
+ "Groups and options are project-defined. Platform, language, light/dark, brand and density are examples, never required dimensions or fixed flags. --locale changes CLI labels only.",
12
+ writes:
13
+ "token theme default --theme Group/Option explicitly saves a project default; the App's own active selection changes only in the App (or with a set-active-token-themes operation through advanced apply). View, validate and export never save a selection.",
14
+ },
15
+ actions: {
16
+ settings:
17
+ "Work by name: every listed write takes Token paths ({path} binds a Token), theme options as Group/Option and component, page, element, canvas, color and font names; the CLI makes IDs, node tables and coordinates. Names never repeat: siblings, pages, components, canvases, theme options and assets each have their own; \"Name [2]\" picks one of a name stored twice so it can be renamed. Only advanced apply takes IDs. All writes are atomic; --dry-run/--explain/--diff preview without writing.",
18
+ definitions:
19
+ "token theme list returns groups/options/defaults; token list --definitions [--group GROUP] reads stored option values; token list/show/explain resolve values and sources.",
20
+ flows:
21
+ "flow list shows starts and links by page and element name; flow link adds a link, flow unlink removes an element's links, flow start --page makes a start and --remove undoes it.",
22
+ view: "view looks at anything by name: nothing (whole package), --tokens/--token, --components/--component [--variant --element], --pages/--page [--platform --element]; --as text|wireframe|png|issues. Outlines collapse component internals and group repeated instances. changes --since REVISION lists, by name, what changed after a revision, including edits made in the App.",
23
+ validate:
24
+ "validate checks canonical validity and reports scoped text/layout/contrast issues separately. status valid means valid data, not complete visual approval. Always read issueCount, visualStatus and coverage.skipped. Pagination does not reduce the checked scope.",
25
+ export:
26
+ "export saves text, page/component/region wireframe or PNG and returns its path/hash/bytes. Pass the same target and themes used for validation.",
27
+ },
28
+ modelling: {
29
+ tokenLayers:
30
+ "Token names may have project-defined primitive/semantic/component layers; theme groups are independent dimensions, not name layers. Same-name tokens override in library set order (last active set wins). Token bindings reference stable token IDs; literals are also valid node values.",
31
+ tokenWrites:
32
+ "Write Tokens by name with token set: value applies wherever no option has its own value, values set named Group/Option options. Add and change theme options with token theme add/rename/default/delete --theme Group/Option. Resolve freely supplied combinations with --theme; do not create a separate saved-combination object.",
33
+ componentVariants:
34
+ "Component variants describe properties/states. Instance text overrides change content for one placement. They are distinct from token theme groups. Component edits affect its shared definition; instance overrides affect one placement.",
35
+ presentations:
36
+ "A Screen has a Base Presentation and optional different structures. Presentations store structure; token options resolve bound values and do not switch node trees or component variants. Context Axes describe finite non-token choices. Existing Scenarios describe page targets/actions; they are optional and are not required to select Token combinations.",
37
+ platforms:
38
+ "Split platform differences by kind. Values (font size, spacing) go to a Token theme group such as Viewport: Desktop / Mobile; without a saved default (token theme default) or an option named Default, its first option is the default, so list the platform you design first first. Structure or visibility inside a component (sidebar vs bottom tabs, a hidden action) goes to a Platform variant property on that component; shared page shells make good components with a Platform property. A whole page laid out differently is another Presentation. Add Platform only to components whose structure differs: on every component it multiplies variants. Checking a platform's Presentation, pass its theme option with --theme; view and validate hint when one exists but is not selected, or when an instance uses another platform's variant.",
39
+ packages:
40
+ "project init creates a blank self-contained package with one canvas and no pages, Tokens or components (page draw makes the first page); --name names the package and its file. In foundation-product layout write shared Tokens/themes/components to Foundation, pages/instances/Scenarios to Product. Read Product to resolve both; Product cannot edit Foundation components.",
41
+ designSystem:
42
+ "Generated from canonical token and component data; do not draw a duplicate design-system Screen. view/export --design-system inspects it. Representative samples do not prove every variant/combination was checked.",
43
+ },
44
+ output: {
45
+ budgetBytes: 8192,
46
+ strategy:
47
+ "Discover IDs and read compact text/issues first; request one target or region before expanding data with pagination or --full. Use a text wireframe for unclear spatial relations. Decode JSON strings, keep LF newlines and spaces, display monospace without soft wrapping. Only request PNG inspection when text/wireframes are insufficient and the caller supports image input; verify the current PNG against renderHash; temporary paths are unique per call. Outline page.total/hasMore counts text lines; wireframe is complete with lineCount. Large complete results use resultFile.path (JSON) and resultFile.text.path for exact LF/space text. Use jq/rg/sed to read just what is needed. --full expands data, --stdout explicitly permits large stdout. All temporary files share one system temporary folder and the format smallpen-TIMESTAMP-UUID-KIND.ext. Each call cleans CLI files with mtime older than 30 minutes, skipping active calls. Consume within 30 minutes; concurrent invocations have separate results. --output retains a chosen deliverable. Omitted stdout detail never means a check passed.",
48
+ imagePolicy:
49
+ "view defaults to a text outline; export defaults to a text wireframe, including with --full. PNG requires export --format png. The CLI cannot detect the caller's image capability and returns artifact paths by default. Models without image input must use text/wireframes for review; they may export PNG deliverables but must not open/embed them or claim visual inspection.",
50
+ protocol:
51
+ "All stdout JSON is compact, including --full. Default directories expose only public identity, ownership, selection and count fields; read one target for its description or definition. Omit normal public visibility and false deprecation; actual private restrictions, deprecation and replacements remain actionable. Internal object fields never become public directory fields automatically. schema command project list or component list/search documents the output contract. token theme list omits unrelated stored App selection unless --full is explicitly requested. This omission policy must not discard meaningful design values such as 0, false, empty text or a node's hidden state.",
52
+ writes:
53
+ "Choose a unique --batch-id before retryable writes; reuse it only with identical input. Read changed fields and changeReportPath for exact before/after values. reverseEdit describes a new revision-guarded write, not history rollback. After later changes restore only the intended current fields with a new edit; never force stale snapshots. Inspect changed/noChange, revisions and scoped reuseReminders. Existing IDs on put operations usually replace the whole object; use update operations for partial edits.",
54
+ },
55
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smallpen/cli",
3
- "version": "0.1.0-alpha.1",
3
+ "version": "0.1.0-alpha.11",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -15,8 +15,8 @@
15
15
  "bin"
16
16
  ],
17
17
  "dependencies": {
18
- "@smallpen/core": "0.1.0-alpha.1",
19
- "@smallpen/local-package": "0.1.0-alpha.1"
18
+ "@smallpen/core": "0.1.0-alpha.11",
19
+ "@smallpen/local-package": "0.1.0-alpha.11"
20
20
  },
21
21
  "repository": {
22
22
  "type": "git",