@sunstack/cli 0.0.0 → 0.1.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/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,190 @@
1
- # @sunstack/cli
1
+ # sunstack exec
2
2
 
3
- The `sunstack` CLI from [sunstack-tools](https://github.com/ShravanSunder/sunstack-tools) lints repository documents and runs native Sunstack checks.
3
+ Run `sunstack exec` (or `cargo run -p sunstack -- exec` from the repository root).
4
+ One process consumes one JSON request on stdin and writes one JSON response on
5
+ stdout. Relative input paths resolve from the process working directory. Run
6
+ from the repository root when using the reviewed examples.
4
7
 
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.
8
+ ```json
9
+ {"protocol_version":1,"operation":"evaluate_rules","input":{}}
10
+ ```
11
+
12
+ Replace `input` with the native operation input. Supported operations are
13
+ `prepare_evidence`, `run_judgments`, `check_rules`, `prepare_rules` and `evaluate_rules`.
14
+ The response is either `protocol_error` with diagnostics, or `operation_result`
15
+ whose `result` contains the operation and its unchanged native outcome.
16
+ The raw stdin limit is 8,388,608 bytes. Bind large evidence by path.
17
+
18
+ Exit 0 means the operation completed, including an unfavorable rule verdict.
19
+ Exit 2 means invalid input or protocol refusal; 3 means failed or incomplete
20
+ execution; 4 means caller cancellation. SIGINT and SIGTERM cancel owned work
21
+ and await its settlement before publishing the terminal response. The static
22
+ `sunstack: ready` stderr line witnesses signal registration, and
23
+ `sunstack: cancellation observed` witnesses signal consumption.
24
+
25
+ Change-scoped work retains the same protocol version. Native inputs
26
+ accept non-Full `change_scope` values (dirty layers, revision ranges, commit lists,
27
+ time windows and unions), bounded discovery, ChangeSet sources and explicit
28
+ package bindings. A relative repository root resolves from this process's CWD.
29
+ For example, select current unstaged changes on a repository binding with:
30
+
31
+ ```json
32
+ {"change_scope":{"kind":"uncommitted","layers":["unstaged"]}}
33
+ ```
34
+
35
+ This is a binding fragment, not a complete invocation. ChangeSet selectors need
36
+ `discovery.kind = "bounded"` with native discovery limits. A package binding's
37
+ empty `target_path_prefix` maps to the target repository root. Use the reviewed
38
+ [invocation schema](../../docs/specs/2026-10-12-rust-rule-engine/schemas/rule-invocation.schema.json)
39
+ for complete inputs.
40
+
41
+ Preparation and reports expose native `change_selections`; Full-only work has
42
+ an empty list. Rule occurrences retain selection reasons such as
43
+ `target_changed` or `full_scope`, pending comparisons, and outside-scope results.
44
+ Preparing rules sends no model request. A waiting gate leaves target comparison
45
+ pending; evaluation resolves it only when the native gate permits the work.
46
+ The CLI forwards these native outcomes and uses the same exit meanings above.
47
+
48
+ The Jev backend reads `OPENROUTER_API_KEY` from the process environment, never
49
+ stdin. Rule preparation uses the selected backend for encoding and accounting
50
+ when model work is reachable; it sends no provider request. Local-only rule
51
+ preparation and evaluation construct no backend and need no credential. A
52
+ model-based judgment, applicability gate or Router still requires valid backend
53
+ configuration when it becomes reachable. Evidence preparation needs no credential.
54
+
55
+ `check_rules` assembles every requested RuleSet and validates its rules without
56
+ constructing a backend, reading reviewed documents or sending model requests.
57
+ Its input contains `definition_root`, `definitions`, `repositories`,
58
+ `payload_schemas`, `limits`, optional `discovery` and `output` (`diagnostics` or `assembled_rules`).
59
+ Discovery defaults to `none`, as in runtime input. Connected rules need bounded
60
+ discovery settings for admission; check never executes discovery or acquisition.
61
+ Repository-root metadata and declared payload schemas are still validated.
62
+ It accepts no backend, attempt or request-budget settings. A clean check exits 0;
63
+ refusals exit 2 with definition file, line and column. Printed rules are returned
64
+ only after a clean check and include each native definition and its source.
65
+
66
+ RuleSets can combine existing `rule_paths` JSONL files with
67
+ `rule_sources: [{"markdown":"standards.md","rules":"standards.rules.json"}]`.
68
+ Paths resolve relative to the RuleSet file. A RuleSet with sources needs a `marker_prefix`
69
+ such as `K`; an end-of-line `<!-- K014 -->` binds its standard to the `K014`
70
+ entry in the strict rules object. Entry `name` becomes the native rule ID;
71
+ `target.input` is always explicit, and optional `context` uses native evidence
72
+ declarations. The target declaration reserves ID `target`. The assembled question
73
+ uses visible Markdown wording, with a default cap of 400 words configurable by
74
+ `max_question_words`. Both the Markdown and rules file are definition dependencies.
75
+
76
+ At runtime, invalid source entries are listed in `skipped_rules`, while valid
77
+ rules continue. An unreadable or malformed source contributes one source-level
78
+ skip. `rule_coverage` is `full`, `partial` or `none`; partial runs can complete
79
+ with exit 0, while no valid rule yields incomplete exit 3. Measurements keep
80
+ `invalid_rule_entries` and `skipped_rule_sources` separate from applicability's
81
+ `rules_skipped`. Existing JSONL, RuleSet-header and invocation errors retain
82
+ their strict refusal behavior.
83
+
84
+ Run the complete credential-free check/print example from the repository root:
85
+
86
+ ```sh
87
+ scripts/cargo-debug-signing-runner.sh target/debug/sunstack exec \
88
+ < docs/specs/2026-10-12-rust-rule-engine/examples/marked-standards.check.request.json
89
+ ```
90
+
91
+ The [check input schema](../../docs/specs/2026-10-12-rust-rule-engine/schemas/check-rules-input.schema.json)
92
+ and [rules-object schema](../../docs/specs/2026-10-12-rust-rule-engine/schemas/rule-source.schema.json)
93
+ describe these boundaries. Preparation and evaluation reports now require coverage,
94
+ rule evaluations, skipped rules and their counters; strict report consumers must
95
+ update together.
96
+
97
+ Document targets can select Markdown units independently:
98
+
99
+ ```json
100
+ {"kind":"markdown_units","mode":"call_site","units":["paragraph","list_item"],"pattern":"\\bMUST\\s+load\\b"}
101
+ ```
102
+
103
+ `mode: unit` produces one occurrence per requested section, paragraph or list
104
+ item, with an optional `starts_with` or `pattern` filter. Requested unit kinds
105
+ can overlap. `mode: call_site` requires exactly one nonempty filter and produces
106
+ one occurrence per match, owned by the innermost requested unit; list items own
107
+ their contained paragraphs. Regexes that can match empty text are refused.
108
+ Filters use visible text with inline code excluded; fenced content remains.
109
+
110
+ Occurrence keys include source identity, raw unit ranges, and call-site raw and
111
+ visible match ranges. Raw spans use original document bytes and character
112
+ coordinates. Several visible matches may share a raw entity span; generated-only
113
+ matches use zero-width raw boundary anchors. Question owners carry the full key,
114
+ so budget splitting preserves each target's evidence and answer references.
115
+
116
+ Preparation exposes `rule_evaluations`, including `waiting_for_expansion` behind
117
+ a staged different-input gate. No child keys or target selections are invented
118
+ while it waits. Evaluation reports `expanded`, `zero_matches`, stopped, failed
119
+ or cancelled work explicitly. Zero matches is complete when other work completes;
120
+ it is never a passed verdict. Same-input speculative carriers remain permitted.
121
+ Existing change-scope repository snapshots may already hold the reviewed bytes;
122
+ canonical target selection and expansion still wait for their gate frontier.
123
+
124
+ The evidence library also supports `frontmatter_field` with a literal top-level
125
+ `field`. An initial YAML `---` block yields a decoded JSON value or an absent
126
+ field. Missing or malformed blocks yield explicit unavailable data without an
127
+ invented span. Noul and composite rules reject this mechanical-only target.
128
+
129
+ A rules entry can replace its judgment threshold with one local check:
130
+
131
+ ```json
132
+ {"name":"skill.description","target":{"input":"skill","select":{"kind":"frontmatter_field","field":"description"}},"expect":{"starts_with":"Use when"},"severity":"error"}
133
+ ```
134
+
135
+ `expect` accepts exactly one of `matches`, `not_matches`, `starts_with` or
136
+ `present: true`. Text predicates use the unit's rendered-visible text, excluding
137
+ inline code. A frontmatter string uses its decoded text; other values use
138
+ canonical JSON text. Null is present. Every predicate fails on an absent field;
139
+ a missing or malformed header is unresolved.
140
+
141
+ `ast_grep` accepts a native rule object validated by ast-grep-config 0.51.0.
142
+ It sees the full original Markdown syntax tree, including ancestors outside
143
+ the unit. A match fully contained in the target unit is a failure; sibling
144
+ matches outside it are excluded. Frontmatter fields use `expect`; runtime unit
145
+ kinds do not select the metadata block. The marked standard becomes the local
146
+ result's explanation and is not sent to a model.
147
+
148
+ Reports use `checked` occurrences with `completed` pass/fail, `unresolved` or
149
+ `cancelled` local outcomes. A failed check retains its declared severity and
150
+ can complete with exit 0. Unresolved checks make the report incomplete. Local
151
+ checks create no model answers, references, receipts or confidence and consume
152
+ no model question/request budget. Applicability and routing still govern them.
153
+
154
+ The [mechanical invocation](../../docs/specs/2026-10-12-rust-rule-engine/examples/mechanical.invocation.json)
155
+ checks a description and a load instruction without a credential. Wrap that
156
+ native input in the usual `evaluate_rules` protocol frame. The companion
157
+ [check request](../../docs/specs/2026-10-12-rust-rule-engine/examples/mechanical.check.request.json)
158
+ validates and prints both standards without reading the reviewed skill.
159
+
160
+ Rust callers supply `RuleBackendFactory` in `RuleExecutionContext`. Selection
161
+ is credential-free; the invocation calls `prepare` once when its first model
162
+ request reaches encoding and caches the result. Concrete built backends already
163
+ implement the factory trait and can still be passed with `Arc::new(backend)`.
164
+
165
+ The backend-free
166
+ [unit-target check request](../../docs/specs/2026-10-12-rust-rule-engine/examples/unit-targets.check.request.json)
167
+ checks a rule that selects every paragraph of the existing example document.
168
+ Like other checks, it validates the rule without reading that document.
169
+
170
+ Build with the pinned root toolchain and the SDK environment described in the
171
+ project verification skill. For a local development invocation, pass a request
172
+ file to `scripts/cargo-debug-signing-runner.sh target/debug/sunstack exec`.
173
+ The reviewed skill-lint input lives in
174
+ `docs/specs/2026-10-12-rust-rule-engine/examples/skill-lint.invocation.json`.
175
+ The handoff example contains illustrative transcript/schema paths; the permanent
176
+ journey fixture supplies the accepted generated schema and a documented Codex
177
+ transcript composition.
178
+
179
+ Run `cargo nextest run -p sunstack` for local process checks.
180
+ The explicitly ignored `journey_measurements` test runs the fixed 40-process
181
+ controlled workload. The two ignored `hosted_journeys` cases make four genuine
182
+ provider invocations and require a command-local credential. They are deliberate
183
+ paid observations; preserve failures and obtain a new campaign decision rather
184
+ than repeating until favorable. The ignored `hosted_request_diagnosis` is an
185
+ offline encoder observation for a preserved failed invocation, with no dispatch.
186
+
187
+ See [milestone 2 proof](tests/MILESTONE2-PROOF.md) for change-scoped and connected
188
+ process coverage and its proof limits. See [phase 2 proof](tests/PHASE2-PROOF.md) for the observed gates, measurements,
189
+ fixture provenance, failed campaign and proof limits. [Phase 1 proof](tests/PHASE1-PROOF.md)
190
+ 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.1.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.1.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.1.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.1.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
+ });