@sunstack/cli 0.0.0 → 0.2.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.
package/.gitignore ADDED
@@ -0,0 +1,2 @@
1
+ /node_modules
2
+
package/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0
4
+
5
+ This pre-1.0 minor release cuts over the lint configuration and rulebook format.
6
+
7
+ - Replace lint's `rule_sets` configuration with marked Markdown `rulebook` pairs,
8
+ checked-file globs, an ast-grep evidence project and `decision_model`. Rule
9
+ entries run explicit mechanical and model decision steps on evidence pieces;
10
+ asking rules always require a configured model and credential.
11
+ - Add `sunstack lint --check` for credential-free admission and rule listing,
12
+ and `--evidence [KIND]` for inspecting picked evidence without model requests.
13
+ `--init` writes a generic configuration skeleton.
14
+ - Report uncertain decisions with their step, sent state and answers. Exit 5
15
+ means uncertainty when cancellation, incomplete work or failing findings do
16
+ not take precedence.
17
+ - Remove native RuleSet `rule_sources`, `marker_prefix` and `max_question_words`.
18
+ Native JSONL `rule_paths` and conversation operations remain supported. Existing
19
+ lint configurations must migrate to the [current configuration](README.md#repository-lint);
20
+ removed keys are refused with replacement guidance.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shravan Sunder
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,5 +1,284 @@
1
- # @sunstack/cli
1
+ # sunstack
2
2
 
3
- The `sunstack` CLI from [sunstack-tools](https://github.com/ShravanSunder/sunstack-tools) lints repository documents and runs native Sunstack checks.
3
+ Lint repository documents with a marked rulebook, ast-grep evidence and explicit
4
+ decision steps. Use `sunstack exec` for the native JSON protocol.
4
5
 
5
- Version 0.0.0 contains no program. It exists so that the release workflow can publish through npm trusted publishing, and the first release replaces it.
6
+ ## Repository lint
7
+
8
+ Create a configuration skeleton with `sunstack lint --init`, then add the
9
+ referenced Markdown rulebook, JSON rule entries and ast-grep project. Init writes
10
+ only `sunstack.lint.json` and refuses to overwrite an existing file.
11
+
12
+ ```json
13
+ {
14
+ "rulebook": [{ "markdown": "rulebook.md", "rules": "rulebook.rules.json" }],
15
+ "marker_prefix": "K",
16
+ "check": { "files": ["docs/**/*.md"], "ignore": ["docs/archive/**"] },
17
+ "evidence": {
18
+ "ast_grep": "evidence/sgconfig.yml",
19
+ "kinds": {
20
+ "piece": {
21
+ "rule": "your-rule-id",
22
+ "fields": { "body": { "from": "remainder" } }
23
+ }
24
+ }
25
+ },
26
+ "decision_model": { "model": "jev-latest" }
27
+ }
28
+ ```
29
+
30
+ Replace `your-rule-id` with a rule in your ast-grep project. An end-of-line marker
31
+ such as `<!-- K001 -->` binds a Markdown standard to its `K001` JSON entry.
32
+ Each entry names an evidence kind and a decision graph. Steps can check fields
33
+ mechanically, ask authored questions, combine answers and route to pass or a
34
+ finding. Questions reach the model as authored, with one piece's bounded field
35
+ view. The binary embeds six generic Markdown utilities; rulebooks, questions and
36
+ domain-specific evidence rules belong to the caller.
37
+
38
+ Rulebook and ast-grep project paths resolve relative to the configuration file.
39
+ Checked-file globs resolve from its Git repository root, or from the config
40
+ directory outside Git. Without `--config`, lint searches the current directory
41
+ and its parents for `sunstack.lint.json`.
42
+
43
+ ```sh
44
+ sunstack lint --config sunstack.lint.json --check
45
+ sunstack lint --config sunstack.lint.json --all --evidence
46
+ sunstack lint --config sunstack.lint.json --all --evidence piece
47
+ sunstack lint --config sunstack.lint.json --all --format json
48
+ ```
49
+
50
+ `--check` validates configuration, the rulebook and evidence project, then lists
51
+ the assembled rules as JSON. It reads no checked files and sends no model
52
+ requests. `--evidence [KIND]` prints picked evidence and its fields as JSON without
53
+ asking the model. Both routes work without a credential.
54
+
55
+ An ordinary lint run uses changed evidence by default and requires Git for that
56
+ default scope. Use `--all` for every configured target, `--since REV` for changes
57
+ since a revision including worktree edits, or name files or directories for full
58
+ scope within those paths. Findings retain their rule, marker and source
59
+ location. Available output formats are `default`, `json`, `github` and `sarif`.
60
+ The JSON report includes `results`, one entry per evaluated rule/piece pair in
61
+ logical report order. Each entry has the rule's `name` and marker, the piece's
62
+ file and first and last line, its outcome (`pass`, `finding`, `not_applicable`,
63
+ `uncertain` or `incomplete`) and its ending `step` (null before any step ran).
64
+
65
+ The decision model is always configured and enabled for asking rules; there is
66
+ no model-off switch. Ordinary runs with asking rules require an OpenRouter
67
+ credential before checked-file acquisition, even if a later mechanical branch
68
+ would avoid an ask. Mechanical-only rules need no key. Credential lookup and
69
+ saving are described below under the native protocol.
70
+
71
+ Lint exits 0 for clean or nothing selected, 1 for error findings or warnings over
72
+ `--max-warnings`, 2 for invalid input, 3 for incomplete work, 4 for cancellation,
73
+ and 5 for uncertain decisions. Uncertainty retains the decision step, sent state
74
+ and answers for the caller to inspect. Cancellation takes precedence over
75
+ incomplete work, then findings, then uncertainty. A warning alone does not fail
76
+ unless it exceeds `--max-warnings`.
77
+
78
+ See the checked-in [worked configuration](tests/fixtures/rulebook-lint/skill-rules.config.json)
79
+ and its [rulebook entries](tests/fixtures/rulebook-lint/rulebook/steering.rules.json)
80
+ for a complete test example. Copy the fixture directory to an isolated root to
81
+ try it; its checked-file globs are relative to that root. The example's
82
+ skill-specific rules are test content.
83
+
84
+ ## Native JSON protocol
85
+
86
+ Run `sunstack exec` (or `cargo run -p sunstack -- exec` from the repository root).
87
+ One process consumes one JSON request on stdin and writes one JSON response on
88
+ stdout. Relative input paths resolve from the process working directory. Run
89
+ from the repository root when using the reviewed examples.
90
+
91
+ ```json
92
+ { "protocol_version": 1, "operation": "evaluate_rules", "input": {} }
93
+ ```
94
+
95
+ Replace `input` with the native operation input. Supported operations are
96
+ `prepare_evidence`, `run_judgments`, `check_rules`, `prepare_rules` and `evaluate_rules`.
97
+ The response is either `protocol_error` with diagnostics, or `operation_result`
98
+ whose `result` contains the operation and its unchanged native outcome.
99
+ The raw stdin limit is 8,388,608 bytes. Bind large evidence by path.
100
+
101
+ Exit 0 means the operation completed, including an unfavorable rule verdict.
102
+ Exit 2 means invalid input or protocol refusal; 3 means failed or incomplete
103
+ execution; 4 means caller cancellation. SIGINT and SIGTERM cancel owned work
104
+ and await its settlement before publishing the terminal response. The static
105
+ `sunstack: ready` stderr line witnesses signal registration, and
106
+ `sunstack: cancellation observed` witnesses signal consumption.
107
+
108
+ Change-scoped work retains the same protocol version. Native inputs
109
+ accept non-Full `change_scope` values (dirty layers, revision ranges, commit lists,
110
+ time windows and unions), bounded discovery, ChangeSet sources and explicit
111
+ package bindings. A relative repository root resolves from this process's CWD.
112
+ For example, select current unstaged changes on a repository binding with:
113
+
114
+ ```json
115
+ { "change_scope": { "kind": "uncommitted", "layers": ["unstaged"] } }
116
+ ```
117
+
118
+ This is a binding fragment, not a complete invocation. ChangeSet selectors need
119
+ `discovery.kind = "bounded"` with native discovery limits. A package binding's
120
+ empty `target_path_prefix` maps to the target repository root. Use the reviewed
121
+ [invocation schema](../../docs/specs/2026-10-12-rust-rule-engine/schemas/rule-invocation.schema.json)
122
+ for complete inputs.
123
+
124
+ Preparation and reports expose native `change_selections`; Full-only work has
125
+ an empty list. Rule occurrences retain selection reasons such as
126
+ `target_changed` or `full_scope`, pending comparisons, and outside-scope results.
127
+ Preparing rules sends no model request. A waiting gate leaves target comparison
128
+ pending; evaluation resolves it only when the native gate permits the work.
129
+ The CLI forwards these native outcomes and uses the same exit meanings above.
130
+
131
+ The binary uses a non-empty `OPENROUTER_API_KEY` first, otherwise the OS credential
132
+ store (`sunstack` / `openrouter-api-key`): macOS login Keychain or Linux Secret
133
+ Service. When the environment key is present, a run never reads or writes the store.
134
+ Runs never save credentials automatically. Use `sunstack credentials save` to save
135
+ the current environment key explicitly; it is listed in `sunstack --help`.
136
+ The command reports its destination without printing the key and refuses a missing
137
+ or empty variable. Set
138
+ `SUNSTACK_CREDENTIAL_STORE=off` to disable both lookup and saving (for CI or a one-off
139
+ key). Library callers pass the key as a value to `sunstack_lint::run`.
140
+ Without a terminal on stdin, macOS access that needs Keychain approval is treated
141
+ as unavailable immediately. Run once from a terminal to approve a changed build,
142
+ or supply the environment key. Interactive terminal runs may show the Keychain dialog.
143
+ On Linux, a locked Secret Service collection may prompt during fallback lookup or explicit saving;
144
+ use `SUNSTACK_CREDENTIAL_STORE=off` for unattended runs.
145
+
146
+ Credentials never come from stdin. Rule preparation uses the selected backend for encoding and accounting
147
+ when model work is reachable; it sends no provider request. Local-only rule
148
+ preparation and evaluation construct no backend and need no credential. A
149
+ model-based judgment, applicability gate or Router still requires valid backend
150
+ configuration when it becomes reachable. Evidence preparation needs no credential.
151
+
152
+ `check_rules` assembles every requested RuleSet and validates its rules without
153
+ constructing a backend, reading reviewed documents or sending model requests.
154
+ Its input contains `definition_root`, `definitions`, `repositories`,
155
+ `payload_schemas`, `limits`, optional `discovery` and `output` (`diagnostics` or `assembled_rules`).
156
+ Discovery defaults to `none`, as in runtime input. Connected rules need bounded
157
+ discovery settings for admission; check never executes discovery or acquisition.
158
+ Repository-root metadata and declared payload schemas are still validated.
159
+ It accepts no backend, attempt or request-budget settings. A clean check exits 0;
160
+ refusals exit 2 with definition file, line and column. Printed rules are returned
161
+ only after a clean check and include each native definition and its source.
162
+
163
+ Native RuleSets load `rule_paths` JSONL files, with paths relative to the RuleSet
164
+ file. Use the repository lint route above for marked Markdown rulebooks. Native
165
+ preparation and evaluation retain their coverage, rule evaluations, skipped rules
166
+ and counters. JSONL, RuleSet-header and invocation errors remain strict refusals.
167
+
168
+ Document targets can select Markdown units independently:
169
+
170
+ ```json
171
+ {
172
+ "kind": "markdown_units",
173
+ "mode": "call_site",
174
+ "units": ["paragraph", "list_item"],
175
+ "pattern": "\\bMUST\\s+load\\b"
176
+ }
177
+ ```
178
+
179
+ `mode: unit` produces one occurrence per requested section, paragraph or list
180
+ item, with an optional `starts_with` or `pattern` filter. Requested unit kinds
181
+ can overlap. `mode: call_site` requires exactly one nonempty filter and produces
182
+ one occurrence per match, owned by the innermost requested unit; list items own
183
+ their contained paragraphs. Regexes that can match empty text are refused.
184
+ Filters use visible text with inline code excluded; fenced content remains.
185
+
186
+ Occurrence keys include source identity, raw unit ranges, and call-site raw and
187
+ visible match ranges. Raw spans use original document bytes and character
188
+ coordinates. Several visible matches may share a raw entity span; generated-only
189
+ matches use zero-width raw boundary anchors. Question owners carry the full key,
190
+ so budget splitting preserves each target's evidence and answer references.
191
+
192
+ Preparation exposes `rule_evaluations`, including `waiting_for_expansion` behind
193
+ a staged different-input gate. No child keys or target selections are invented
194
+ while it waits. Evaluation reports `expanded`, `zero_matches`, stopped, failed
195
+ or cancelled work explicitly. Zero matches is complete when other work completes;
196
+ it is never a passed verdict. Same-input speculative carriers remain permitted.
197
+ Existing change-scope repository snapshots may already hold the reviewed bytes;
198
+ canonical target selection and expansion still wait for their gate frontier.
199
+
200
+ The evidence library also supports `frontmatter_field` with a literal top-level
201
+ `field`. An initial YAML `---` block yields a decoded JSON value or an absent
202
+ field. Missing or malformed blocks yield explicit unavailable data without an
203
+ invented span. Noul and composite rules reject this mechanical-only target.
204
+
205
+ A rules entry can use a native mechanical verdict. Put the check under
206
+ `verdict.check` and select the checked field in the evidence declaration:
207
+
208
+ ```json
209
+ {
210
+ "rule_id": "skill.description",
211
+ "evidence": [
212
+ {
213
+ "id": "description",
214
+ "input": "subject",
215
+ "requirement": "required",
216
+ "select": { "kind": "frontmatter_field", "field": "description" }
217
+ }
218
+ ],
219
+ "target": "description",
220
+ "applicability": [],
221
+ "questions": {},
222
+ "verdict": {
223
+ "kind": "mechanical",
224
+ "explanation": "Description names when to use the skill.",
225
+ "check": { "kind": "expect", "expect": { "starts_with": "Use when" } }
226
+ },
227
+ "severity": "error"
228
+ }
229
+ ```
230
+
231
+ Save each native definition as one JSONL line in a RuleSet's `rule_paths`, and
232
+ declare its `subject` input as a document in the RuleSet header. Bind that input
233
+ to the document when calling `check_rules` or `evaluate_rules`.
234
+
235
+ `verdict.check.expect` accepts exactly one of `matches`, `not_matches`, `starts_with` or
236
+ `present: true`. Text predicates use the unit's rendered-visible text, excluding
237
+ inline code. A frontmatter string uses its decoded text; other values use
238
+ canonical JSON text. Null is present. Every predicate fails on an absent field;
239
+ a missing or malformed header is unresolved.
240
+
241
+ For an AST check, use `verdict.check: {"kind":"ast_grep","ast_grep":{...}}`.
242
+ `ast_grep` accepts a native rule object validated by ast-grep-config 0.51.0.
243
+ It sees the full original Markdown syntax tree, including ancestors outside
244
+ the unit. A match fully contained in the target unit is a failure; sibling
245
+ matches outside it are excluded. Frontmatter fields use `expect`; runtime unit
246
+ kinds do not select the metadata block. Local checks send no model request.
247
+
248
+ Reports use `checked` occurrences with `completed` pass/fail, `unresolved` or
249
+ `cancelled` local outcomes. A failed check retains its declared severity and
250
+ can complete with exit 0. Unresolved checks make the report incomplete. Local
251
+ checks create no model answers, references, receipts or confidence and consume
252
+ no model question/request budget. Applicability and routing still govern them.
253
+
254
+ Rust callers supply `RuleBackendFactory` in `RuleExecutionContext`. Selection
255
+ is credential-free; the invocation calls `prepare` once when its first model
256
+ request reaches encoding and caches the result. Concrete built backends already
257
+ implement the factory trait and can still be passed with `Arc::new(backend)`.
258
+
259
+ The backend-free
260
+ [unit-target check request](../../docs/specs/2026-10-12-rust-rule-engine/examples/unit-targets.check.request.json)
261
+ checks a rule that selects every paragraph of the existing example document.
262
+ Like other checks, it validates the rule without reading that document.
263
+
264
+ Build with the pinned root toolchain and the SDK environment described in the
265
+ project verification skill. For a local development invocation, pass a request
266
+ file to `scripts/cargo-debug-signing-runner.sh target/debug/sunstack exec`.
267
+ The reviewed skill-lint input lives in
268
+ `docs/specs/2026-10-12-rust-rule-engine/examples/skill-lint.invocation.json`.
269
+ The handoff example contains illustrative transcript/schema paths; the permanent
270
+ journey fixture supplies the accepted generated schema and a documented Codex
271
+ transcript composition.
272
+
273
+ Run `cargo nextest run -p sunstack` for local process checks.
274
+ The explicitly ignored `journey_measurements` test runs the fixed 40-process
275
+ controlled workload. The two ignored `hosted_journeys` cases make four genuine
276
+ provider invocations and require a command-local credential. They are deliberate
277
+ paid observations; preserve failures and obtain a new campaign decision rather
278
+ than repeating until favorable. The ignored `hosted_request_diagnosis` is an
279
+ offline encoder observation for a preserved failed invocation, with no dispatch.
280
+
281
+ See [milestone 2 proof](tests/MILESTONE2-PROOF.md) for change-scoped and connected
282
+ process coverage and its proof limits. See [phase 2 proof](tests/PHASE2-PROOF.md) for the observed gates, measurements,
283
+ fixture provenance, failed campaign and proof limits. [Phase 1 proof](tests/PHASE1-PROOF.md)
284
+ is a historical account of the earlier two-operation target.
@@ -0,0 +1,348 @@
1
+ const {
2
+ createWriteStream,
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtemp,
6
+ rmSync,
7
+ } = require("fs");
8
+ const { join, sep } = require("path");
9
+ const { spawnSync } = require("child_process");
10
+ const { tmpdir } = require("os");
11
+
12
+ const https = require("node:https");
13
+ const http = require("node:http");
14
+
15
+ const tmpDir = tmpdir();
16
+
17
+ const error = (msg) => {
18
+ console.error(msg);
19
+ process.exit(1);
20
+ };
21
+
22
+ function getProxyForUrl(urlString) {
23
+ const url = new URL(urlString);
24
+ const isHttps = url.protocol === "https:";
25
+
26
+ const noProxy = process.env.NO_PROXY || process.env.no_proxy || "";
27
+ if (noProxy === "*") return null;
28
+ if (noProxy) {
29
+ const hostname = url.hostname.toLowerCase();
30
+ const noProxyList = noProxy.split(",").map((s) => s.trim().toLowerCase());
31
+ for (const entry of noProxyList) {
32
+ if (hostname === entry || hostname.endsWith("." + entry)) {
33
+ return null;
34
+ }
35
+ }
36
+ }
37
+
38
+ const proxyEnv = isHttps
39
+ ? process.env.HTTPS_PROXY || process.env.https_proxy
40
+ : process.env.HTTP_PROXY || process.env.http_proxy;
41
+
42
+ if (!proxyEnv) return null;
43
+
44
+ const proxyUrl = new URL(proxyEnv);
45
+
46
+ let auth = null;
47
+ if (proxyUrl.username || proxyUrl.password) {
48
+ auth = `${proxyUrl.username}:${proxyUrl.password}`;
49
+ }
50
+
51
+ return {
52
+ hostname: proxyUrl.hostname,
53
+ port: proxyUrl.port || (proxyUrl.protocol === "https:" ? 443 : 80),
54
+ auth: auth,
55
+ };
56
+ }
57
+
58
+ function connectThroughProxy(proxy, target) {
59
+ return new Promise((resolve, reject) => {
60
+ const headers = {};
61
+ if (proxy.auth) {
62
+ headers["Proxy-Authorization"] =
63
+ "Basic " + Buffer.from(proxy.auth).toString("base64");
64
+ }
65
+
66
+ const connectReq = http.request({
67
+ hostname: proxy.hostname,
68
+ port: proxy.port,
69
+ method: "CONNECT",
70
+ path: `${target.hostname}:${target.port || 443}`,
71
+ headers,
72
+ });
73
+ connectReq.on("connect", (res, socket) => {
74
+ if (res.statusCode === 200) {
75
+ resolve(socket);
76
+ } else {
77
+ reject(new Error(`Proxy CONNECT failed with status ${res.statusCode}`));
78
+ }
79
+ });
80
+ connectReq.on("error", reject);
81
+ connectReq.end();
82
+ });
83
+ }
84
+
85
+ function download(urlString, maxRedirects) {
86
+ if (maxRedirects === undefined) maxRedirects = 5;
87
+ return new Promise((resolve, reject) => {
88
+ if (maxRedirects < 0) {
89
+ return reject(new Error("Too many redirects"));
90
+ }
91
+
92
+ const parsed = new URL(urlString);
93
+ const isHttps = parsed.protocol === "https:";
94
+ const mod = isHttps ? https : http;
95
+ const proxy = getProxyForUrl(urlString);
96
+
97
+ const doRequest = (extraOptions) => {
98
+ const options = Object.assign(
99
+ {
100
+ hostname: parsed.hostname,
101
+ port: parsed.port || (isHttps ? 443 : 80),
102
+ path: parsed.pathname + parsed.search,
103
+ method: "GET",
104
+ headers: { "User-Agent": "cargo-dist-npm-installer" },
105
+ },
106
+ extraOptions || {},
107
+ );
108
+
109
+ if (proxy && !isHttps) {
110
+ // HTTP through HTTP proxy: request the full URL via the proxy
111
+ options.hostname = proxy.hostname;
112
+ options.port = proxy.port;
113
+ options.path = urlString;
114
+ if (proxy.auth) {
115
+ options.headers["Proxy-Authorization"] =
116
+ "Basic " + Buffer.from(proxy.auth).toString("base64");
117
+ }
118
+ }
119
+
120
+ const req = mod.request(options, (res) => {
121
+ if (
122
+ res.statusCode >= 300 &&
123
+ res.statusCode < 400 &&
124
+ res.headers.location
125
+ ) {
126
+ res.resume();
127
+ const nextUrl = new URL(res.headers.location, urlString).toString();
128
+ return download(nextUrl, maxRedirects - 1).then(resolve, reject);
129
+ }
130
+ if (res.statusCode < 200 || res.statusCode >= 300) {
131
+ res.resume();
132
+ return reject(new Error(`HTTP ${res.statusCode} from ${urlString}`));
133
+ }
134
+ resolve(res);
135
+ });
136
+ req.on("error", reject);
137
+ req.end();
138
+ };
139
+
140
+ if (proxy && isHttps) {
141
+ connectThroughProxy(proxy, parsed).then(
142
+ (socket) => doRequest({ socket, agent: false }),
143
+ reject,
144
+ );
145
+ } else {
146
+ doRequest();
147
+ }
148
+ });
149
+ }
150
+
151
+ class Package {
152
+ constructor(platform, name, url, filename, zipExt, binaries) {
153
+ let errors = [];
154
+ if (typeof url !== "string") {
155
+ errors.push("url must be a string");
156
+ } else {
157
+ try {
158
+ new URL(url);
159
+ } catch (e) {
160
+ errors.push(e);
161
+ }
162
+ }
163
+ if (name && typeof name !== "string") {
164
+ errors.push("package name must be a string");
165
+ }
166
+ if (!name) {
167
+ errors.push("You must specify the name of your package");
168
+ }
169
+ if (binaries && typeof binaries !== "object") {
170
+ errors.push("binaries must be a string => string map");
171
+ }
172
+ if (!binaries) {
173
+ errors.push("You must specify the binaries in the package");
174
+ }
175
+
176
+ if (errors.length > 0) {
177
+ let errorMsg =
178
+ "One or more of the parameters you passed to the Binary constructor are invalid:\n";
179
+ errors.forEach((error) => {
180
+ errorMsg += error;
181
+ });
182
+ errorMsg +=
183
+ '\n\nCorrect usage: new Package("my-binary", "https://example.com/binary/download.tar.gz", {"my-binary": "my-binary"})';
184
+ error(errorMsg);
185
+ }
186
+
187
+ this.platform = platform;
188
+ this.url = url;
189
+ this.name = name;
190
+ this.filename = filename;
191
+ this.zipExt = zipExt;
192
+ this.installDirectory = join(__dirname, "node_modules", ".bin_real");
193
+ this.binaries = binaries;
194
+
195
+ if (!existsSync(this.installDirectory)) {
196
+ mkdirSync(this.installDirectory, { recursive: true });
197
+ }
198
+ }
199
+
200
+ exists() {
201
+ for (const binaryName in this.binaries) {
202
+ const binRelPath = this.binaries[binaryName];
203
+ const binPath = join(this.installDirectory, binRelPath);
204
+ if (!existsSync(binPath)) {
205
+ return false;
206
+ }
207
+ }
208
+ return true;
209
+ }
210
+
211
+ install(suppressLogs = false) {
212
+ if (this.exists()) {
213
+ if (!suppressLogs) {
214
+ console.error(
215
+ `${this.name} is already installed, skipping installation.`,
216
+ );
217
+ }
218
+ return Promise.resolve();
219
+ }
220
+
221
+ try {
222
+ rmSync(this.installDirectory, { recursive: true, force: true });
223
+ } catch {
224
+ // ignore - directory may not exist
225
+ }
226
+
227
+ mkdirSync(this.installDirectory, { recursive: true });
228
+
229
+ if (!suppressLogs) {
230
+ console.error(`Downloading release from ${this.url}`);
231
+ }
232
+
233
+ return download(this.url)
234
+ .then((res) => {
235
+ return new Promise((resolve, reject) => {
236
+ mkdtemp(`${tmpDir}${sep}`, (err, directory) => {
237
+ if (err) return reject(err);
238
+ let tempFile = join(directory, this.filename);
239
+ const sink = res.pipe(createWriteStream(tempFile));
240
+ sink.on("error", (err) => reject(err));
241
+ sink.on("close", () => {
242
+ if (/\.tar\.*/.test(this.zipExt)) {
243
+ const result = spawnSync("tar", [
244
+ "xf",
245
+ tempFile,
246
+ // The tarballs are stored with a leading directory
247
+ // component; we strip one component in the
248
+ // shell installers too.
249
+ "--strip-components",
250
+ "1",
251
+ "-C",
252
+ this.installDirectory,
253
+ ]);
254
+ if (result.status == 0) {
255
+ resolve();
256
+ } else if (result.error) {
257
+ reject(result.error);
258
+ } else {
259
+ reject(
260
+ new Error(
261
+ `An error occurred untarring the artifact: stdout: ${result.stdout}; stderr: ${result.stderr}`,
262
+ ),
263
+ );
264
+ }
265
+ } else if (this.zipExt == ".zip") {
266
+ let result;
267
+ if (this.platform.artifactName.includes("windows")) {
268
+ // Windows does not have "unzip" by default on many installations, instead
269
+ // we use Expand-Archive from powershell
270
+ result = spawnSync("powershell.exe", [
271
+ "-NoProfile",
272
+ "-NonInteractive",
273
+ "-Command",
274
+ `& {
275
+ param([string]$LiteralPath, [string]$DestinationPath)
276
+ Expand-Archive -LiteralPath $LiteralPath -DestinationPath $DestinationPath -Force
277
+ }`,
278
+ tempFile,
279
+ this.installDirectory,
280
+ ]);
281
+ } else {
282
+ result = spawnSync("unzip", [
283
+ "-q",
284
+ tempFile,
285
+ "-d",
286
+ this.installDirectory,
287
+ ]);
288
+ }
289
+
290
+ if (result.status == 0) {
291
+ resolve();
292
+ } else if (result.error) {
293
+ reject(result.error);
294
+ } else {
295
+ reject(
296
+ new Error(
297
+ `An error occurred unzipping the artifact: stdout: ${result.stdout}; stderr: ${result.stderr}`,
298
+ ),
299
+ );
300
+ }
301
+ } else {
302
+ reject(
303
+ new Error(`Unrecognized file extension: ${this.zipExt}`),
304
+ );
305
+ }
306
+ });
307
+ });
308
+ });
309
+ })
310
+ .then(() => {
311
+ if (!suppressLogs) {
312
+ console.error(`${this.name} has been installed!`);
313
+ }
314
+ })
315
+ .catch((e) => {
316
+ error(`Error fetching release: ${e.message}`);
317
+ });
318
+ }
319
+
320
+ run(binaryName) {
321
+ const promise = !this.exists() ? this.install(true) : Promise.resolve();
322
+
323
+ promise
324
+ .then(() => {
325
+ const [, , ...args] = process.argv;
326
+
327
+ const options = { cwd: process.cwd(), stdio: "inherit" };
328
+
329
+ const binRelPath = this.binaries[binaryName];
330
+ if (!binRelPath) {
331
+ error(`${binaryName} is not a known binary in ${this.name}`);
332
+ }
333
+ const binPath = join(this.installDirectory, binRelPath);
334
+ const result = spawnSync(binPath, args, options);
335
+
336
+ if (result.error) {
337
+ error(result.error);
338
+ }
339
+
340
+ process.exit(result.status);
341
+ })
342
+ .catch((e) => {
343
+ error(e.message);
344
+ });
345
+ }
346
+ }
347
+
348
+ module.exports.Package = Package;
package/binary.js ADDED
@@ -0,0 +1,124 @@
1
+ const { Package } = require("./binary-install");
2
+ const os = require("os");
3
+ const libc = require("detect-libc");
4
+
5
+ const error = (msg) => {
6
+ console.error(msg);
7
+ process.exit(1);
8
+ };
9
+
10
+ const {
11
+ name,
12
+ artifactDownloadUrls,
13
+ supportedPlatforms,
14
+ glibcMinimum,
15
+ } = require("./package.json");
16
+
17
+ // FIXME: implement NPM installer handling of fallback download URLs
18
+ const artifactDownloadUrl = artifactDownloadUrls[0];
19
+ const builderGlibcMajorVersion = glibcMinimum.major;
20
+ const builderGlibcMinorVersion = glibcMinimum.series;
21
+
22
+ const getPlatform = () => {
23
+ const rawOsType = os.type();
24
+ const rawArchitecture = os.arch();
25
+
26
+ // We want to use rust-style target triples as the canonical key
27
+ // for a platform, so translate the "os" library's concepts into rust ones
28
+ let osType = "";
29
+ switch (rawOsType) {
30
+ case "Windows_NT":
31
+ osType = "pc-windows-msvc";
32
+ break;
33
+ case "Darwin":
34
+ osType = "apple-darwin";
35
+ break;
36
+ case "Linux":
37
+ osType = "unknown-linux-gnu";
38
+ break;
39
+ }
40
+
41
+ let arch = "";
42
+ switch (rawArchitecture) {
43
+ case "x64":
44
+ arch = "x86_64";
45
+ break;
46
+ case "arm64":
47
+ arch = "aarch64";
48
+ break;
49
+ }
50
+
51
+ if (rawOsType === "Linux") {
52
+ if (libc.familySync() == "musl") {
53
+ osType = "unknown-linux-musl-dynamic";
54
+ } else if (libc.isNonGlibcLinuxSync()) {
55
+ console.warn(
56
+ "Your libc is neither glibc nor musl; trying static musl binary instead",
57
+ );
58
+ osType = "unknown-linux-musl-static";
59
+ } else {
60
+ let libcVersion = libc.versionSync();
61
+ let splitLibcVersion = libcVersion.split(".");
62
+ let libcMajorVersion = splitLibcVersion[0];
63
+ let libcMinorVersion = splitLibcVersion[1];
64
+ if (
65
+ libcMajorVersion != builderGlibcMajorVersion ||
66
+ libcMinorVersion < builderGlibcMinorVersion
67
+ ) {
68
+ // We can't run the glibc binaries, but we can run the static musl ones
69
+ // if they exist
70
+ console.warn(
71
+ "Your glibc isn't compatible; trying static musl binary instead",
72
+ );
73
+ osType = "unknown-linux-musl-static";
74
+ }
75
+ }
76
+ }
77
+
78
+ // Assume the above succeeded and build a target triple to look things up with.
79
+ // If any of it failed, this lookup will fail and we'll handle it like normal.
80
+ let targetTriple = `${arch}-${osType}`;
81
+ let platform = supportedPlatforms[targetTriple];
82
+
83
+ if (!platform) {
84
+ error(
85
+ `Platform with type "${rawOsType}" and architecture "${rawArchitecture}" is not supported by ${name}.\nYour system must be one of the following:\n\n${Object.keys(
86
+ supportedPlatforms,
87
+ ).join(",")}`,
88
+ );
89
+ }
90
+
91
+ return platform;
92
+ };
93
+
94
+ const getPackage = () => {
95
+ const platform = getPlatform();
96
+ const url = `${artifactDownloadUrl}/${platform.artifactName}`;
97
+ let filename = platform.artifactName;
98
+ let ext = platform.zipExt;
99
+ let binary = new Package(platform, name, url, filename, ext, platform.bins);
100
+
101
+ return binary;
102
+ };
103
+
104
+ const install = (suppressLogs) => {
105
+ if (!artifactDownloadUrl || artifactDownloadUrl.length === 0) {
106
+ console.warn("in demo mode, not installing binaries");
107
+ return;
108
+ }
109
+ const pkg = getPackage();
110
+
111
+ return pkg.install(suppressLogs);
112
+ };
113
+
114
+ const run = (binaryName) => {
115
+ const pkg = getPackage();
116
+
117
+ pkg.run(binaryName);
118
+ };
119
+
120
+ module.exports = {
121
+ install,
122
+ run,
123
+ getPackage,
124
+ };
package/install.js ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { install } = require("./binary");
4
+ install(false);
@@ -0,0 +1,52 @@
1
+ {
2
+ "lockfileVersion": 3,
3
+ "name": "@sunstack/cli",
4
+ "packages": {
5
+ "": {
6
+ "bin": {
7
+ "sunstack": "run-sunstack.js"
8
+ },
9
+ "dependencies": {
10
+ "detect-libc": "^2.1.2"
11
+ },
12
+ "devDependencies": {
13
+ "prettier": "^3.8.3"
14
+ },
15
+ "engines": {
16
+ "node": ">=14.14",
17
+ "npm": ">=6"
18
+ },
19
+ "hasInstallScript": true,
20
+ "license": "MIT",
21
+ "name": "@sunstack/cli",
22
+ "version": "0.2.0"
23
+ },
24
+ "node_modules/detect-libc": {
25
+ "engines": {
26
+ "node": ">=8"
27
+ },
28
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
29
+ "license": "Apache-2.0",
30
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
31
+ "version": "2.1.2"
32
+ },
33
+ "node_modules/prettier": {
34
+ "bin": {
35
+ "prettier": "bin/prettier.cjs"
36
+ },
37
+ "dev": true,
38
+ "engines": {
39
+ "node": ">=14"
40
+ },
41
+ "funding": {
42
+ "url": "https://github.com/prettier/prettier?sponsor=1"
43
+ },
44
+ "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
45
+ "license": "MIT",
46
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
47
+ "version": "3.8.3"
48
+ }
49
+ },
50
+ "requires": true,
51
+ "version": "0.2.0"
52
+ }
package/package.json CHANGED
@@ -1,12 +1,96 @@
1
1
  {
2
- "name": "@sunstack/cli",
3
- "version": "0.0.0",
4
- "description": "Placeholder for the sunstack CLI (lint repository documents and execute native Sunstack checks). The first release replaces it.",
5
- "repository": {
6
- "type": "git",
7
- "url": "git+https://github.com/ShravanSunder/sunstack-tools.git"
2
+ "artifactDownloadUrls": [
3
+ "https://github.com/ShravanSunder/sunstack-tools/releases/download/v0.2.0"
4
+ ],
5
+ "bin": {
6
+ "sunstack": "run-sunstack.js"
7
+ },
8
+ "dependencies": {
9
+ "detect-libc": "^2.1.2"
10
+ },
11
+ "description": "Lint repository documents and execute native Sunstack checks",
12
+ "devDependencies": {
13
+ "prettier": "^3.8.3"
14
+ },
15
+ "engines": {
16
+ "node": ">=14.14",
17
+ "npm": ">=6"
8
18
  },
19
+ "glibcMinimum": {
20
+ "major": 2,
21
+ "series": 31
22
+ },
23
+ "homepage": "https://github.com/ShravanSunder/sunstack-tools",
9
24
  "license": "MIT",
10
- "files": ["README.md"],
11
- "publishConfig": { "access": "public" }
12
- }
25
+ "name": "@sunstack/cli",
26
+ "preferUnplugged": true,
27
+ "repository": "https://github.com/ShravanSunder/sunstack-tools",
28
+ "scripts": {
29
+ "fmt": "prettier --write **/*.js",
30
+ "fmt:check": "prettier --check **/*.js",
31
+ "postinstall": "node ./install.js"
32
+ },
33
+ "supportedPlatforms": {
34
+ "aarch64-apple-darwin": {
35
+ "artifactName": "sunstack-aarch64-apple-darwin.tar.gz",
36
+ "bins": {
37
+ "sunstack": "sunstack"
38
+ },
39
+ "zipExt": ".tar.gz"
40
+ },
41
+ "aarch64-unknown-linux-gnu": {
42
+ "artifactName": "sunstack-aarch64-unknown-linux-musl.tar.gz",
43
+ "bins": {
44
+ "sunstack": "sunstack"
45
+ },
46
+ "zipExt": ".tar.gz"
47
+ },
48
+ "aarch64-unknown-linux-musl-dynamic": {
49
+ "artifactName": "sunstack-aarch64-unknown-linux-musl.tar.gz",
50
+ "bins": {
51
+ "sunstack": "sunstack"
52
+ },
53
+ "zipExt": ".tar.gz"
54
+ },
55
+ "aarch64-unknown-linux-musl-static": {
56
+ "artifactName": "sunstack-aarch64-unknown-linux-musl.tar.gz",
57
+ "bins": {
58
+ "sunstack": "sunstack"
59
+ },
60
+ "zipExt": ".tar.gz"
61
+ },
62
+ "x86_64-apple-darwin": {
63
+ "artifactName": "sunstack-x86_64-apple-darwin.tar.gz",
64
+ "bins": {
65
+ "sunstack": "sunstack"
66
+ },
67
+ "zipExt": ".tar.gz"
68
+ },
69
+ "x86_64-unknown-linux-gnu": {
70
+ "artifactName": "sunstack-x86_64-unknown-linux-musl.tar.gz",
71
+ "bins": {
72
+ "sunstack": "sunstack"
73
+ },
74
+ "zipExt": ".tar.gz"
75
+ },
76
+ "x86_64-unknown-linux-musl-dynamic": {
77
+ "artifactName": "sunstack-x86_64-unknown-linux-musl.tar.gz",
78
+ "bins": {
79
+ "sunstack": "sunstack"
80
+ },
81
+ "zipExt": ".tar.gz"
82
+ },
83
+ "x86_64-unknown-linux-musl-static": {
84
+ "artifactName": "sunstack-x86_64-unknown-linux-musl.tar.gz",
85
+ "bins": {
86
+ "sunstack": "sunstack"
87
+ },
88
+ "zipExt": ".tar.gz"
89
+ }
90
+ },
91
+ "version": "0.2.0",
92
+ "volta": {
93
+ "node": "18.14.1",
94
+ "npm": "9.5.0"
95
+ }
96
+ }
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ // Keep cargo-dist installation; async execution preserves native cancellation and exits.
3
+ const { spawn } = require('node:child_process');
4
+ const { join } = require('node:path');
5
+ const { getPackage } = require('./binary');
6
+
7
+ async function runSunstack() {
8
+ const binaryPackage = getPackage();
9
+ if (!binaryPackage.exists()) await binaryPackage.install(true);
10
+ const child = spawn(
11
+ join(binaryPackage.installDirectory, binaryPackage.binaries.sunstack),
12
+ process.argv.slice(2),
13
+ { stdio: 'inherit' },
14
+ );
15
+ /** @type {NodeJS.Signals[]} */
16
+ const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
17
+ const handlers = new Map(signals.map((signal) => [signal, () => child.kill(signal)]));
18
+ for (const [signal, handler] of handlers) process.on(signal, handler);
19
+ child.on('error', (error) => {
20
+ process.stderr.write(`${error.message}\n`);
21
+ process.exitCode = 1;
22
+ });
23
+ child.on('close', (code, signal) => {
24
+ for (const [forwarded, handler] of handlers) process.removeListener(forwarded, handler);
25
+ if (signal) process.kill(process.pid, signal);
26
+ else process.exitCode = code ?? 1;
27
+ });
28
+ }
29
+
30
+ runSunstack().catch((error) => {
31
+ process.stderr.write(`${error.message}\n`);
32
+ process.exitCode = 1;
33
+ });