@archstone/cli 0.13.0 → 0.15.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/dist/index.js +506 -27
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
package/dist/index.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { writeFileSync as
|
|
5
|
-
import { resolve as
|
|
4
|
+
import { writeFileSync as writeFileSync3 } from "fs";
|
|
5
|
+
import { resolve as resolve3 } from "path";
|
|
6
6
|
import { createRequire } from "module";
|
|
7
7
|
import { createServer } from "http";
|
|
8
|
-
import { load } from "@archstone/schema";
|
|
9
|
-
import { validateSemantics, compile } from "@archstone/compiler";
|
|
10
|
-
import { Registry, buildRegistry, serveStdio, runVerify } from "@archstone/runtime";
|
|
8
|
+
import { load as load2 } from "@archstone/schema";
|
|
9
|
+
import { validateSemantics as validateSemantics2, compile as compile2 } from "@archstone/compiler";
|
|
10
|
+
import { Registry as Registry2, buildRegistry, serveStdio, runVerify } from "@archstone/runtime";
|
|
11
11
|
import { createHttpHandler } from "@archstone/runtime/http";
|
|
12
12
|
|
|
13
13
|
// src/init.ts
|
|
@@ -596,6 +596,451 @@ function runAuditCmd(argv) {
|
|
|
596
596
|
return 0;
|
|
597
597
|
}
|
|
598
598
|
|
|
599
|
+
// src/doctor.ts
|
|
600
|
+
import { readFileSync as readFileSync3, existsSync as existsSync2 } from "fs";
|
|
601
|
+
import { join as join2 } from "path";
|
|
602
|
+
var ENV_INTERP = /\$\{[A-Za-z_][A-Za-z0-9_]*\}/;
|
|
603
|
+
var CALLER_INTERP = /\$\{caller\./;
|
|
604
|
+
function baseUrlOf(tool) {
|
|
605
|
+
return tool.connector?.rest?.baseUrl;
|
|
606
|
+
}
|
|
607
|
+
function diagnose(ir, manifestDir, opts = {}) {
|
|
608
|
+
const findings = [];
|
|
609
|
+
const add = (f) => findings.push(f);
|
|
610
|
+
for (const tool of ir.tools) {
|
|
611
|
+
const bound = tool.connector !== void 0;
|
|
612
|
+
if (!bound && tool.lifecycle !== "retired") {
|
|
613
|
+
add({
|
|
614
|
+
severity: "warning",
|
|
615
|
+
code: "unbound-capability",
|
|
616
|
+
capability: tool.id,
|
|
617
|
+
message: "declared but has no binding, so it is not invocable",
|
|
618
|
+
because: "A capability with no binding compiles and then does nothing. That is fine while you are drafting and a defect at go-live."
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
if (bound && !tool.contract && tool.effect === "read") {
|
|
622
|
+
add({
|
|
623
|
+
severity: "warning",
|
|
624
|
+
code: "no-contract",
|
|
625
|
+
capability: tool.id,
|
|
626
|
+
message: "bound, but records no contract fixture",
|
|
627
|
+
because: "`archstone verify` replays a recorded fixture against the live backend. With no fixture there is nothing to replay, so backend drift is found by an agent, in front of a customer, instead of by CI."
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
if (bound && !tool.contract && tool.effect !== "read") {
|
|
631
|
+
add({
|
|
632
|
+
// `advisory`, not `warning`: on a `write`/`irreversible` capability, having no contract
|
|
633
|
+
// fixture is now the CORRECT state, not a gap to close. `warning` would keep asking for
|
|
634
|
+
// the thing this advisory exists to stop recommending.
|
|
635
|
+
severity: "advisory",
|
|
636
|
+
code: "no-contract-non-read",
|
|
637
|
+
capability: tool.id,
|
|
638
|
+
message: `bound and \`${tool.effect}\`, so it records no contract fixture \u2014 and should not`,
|
|
639
|
+
because: "`archstone verify` replays a recorded fixture as a real invocation, so a fixture here would repeat this capability's effect against the live backend on every CI run. `verify` skips it by default for that reason. Where this capability has a `read` counterpart, cover the drift with that instead \u2014 the quote half of a quote \u2192 commit pair hits the same host, auth and serialization at zero risk. Not every write has one, and Archstone cannot tell you which capability it is: nothing in CDL declares that relationship. Only if this binding's `${VAR}` genuinely resolves to a sandbox tenant is recording one worthwhile, replayed with `archstone verify --sandbox`: the flag re-includes the binding, it does not make the backend safe."
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
if (tool.contract?.probeFixture) {
|
|
643
|
+
const fixture = join2(manifestDir, tool.contract.probeFixture);
|
|
644
|
+
if (!existsSync2(fixture)) {
|
|
645
|
+
add({
|
|
646
|
+
severity: "error",
|
|
647
|
+
code: "missing-fixture-file",
|
|
648
|
+
capability: tool.id,
|
|
649
|
+
message: `contract names a fixture that is not on disk: ${tool.contract.probeFixture}`,
|
|
650
|
+
because: "The contract points at a file that does not exist, so `verify` cannot run at all \u2014 a green pipeline that never checked anything."
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
if (tool.lifecycle === "retired" && tool.contract) {
|
|
655
|
+
add({
|
|
656
|
+
severity: "advisory",
|
|
657
|
+
code: "retired-with-contract",
|
|
658
|
+
capability: tool.id,
|
|
659
|
+
message: "is retired but still carries a contract fixture",
|
|
660
|
+
because: "Retired capabilities are blocked on every surface, so the fixture is dead weight \u2014 harmless, but it makes the manifest read as though the capability is still live."
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
const baseUrl = baseUrlOf(tool);
|
|
664
|
+
if (baseUrl && CALLER_INTERP.test(baseUrl)) {
|
|
665
|
+
add({
|
|
666
|
+
severity: "error",
|
|
667
|
+
code: "caller-influenced-baseurl",
|
|
668
|
+
capability: tool.id,
|
|
669
|
+
message: "baseUrl interpolates caller-supplied data",
|
|
670
|
+
because: "This is the SSRF shape: a caller who chooses part of the URL chooses where the request goes. Set `allowedHosts` on the provider, which constrains the resolved host to an allowlist."
|
|
671
|
+
});
|
|
672
|
+
} else if (baseUrl && ENV_INTERP.test(baseUrl)) {
|
|
673
|
+
add({
|
|
674
|
+
severity: "advisory",
|
|
675
|
+
code: "env-baseurl",
|
|
676
|
+
capability: tool.id,
|
|
677
|
+
message: `baseUrl comes from the environment (${baseUrl})`,
|
|
678
|
+
because: "Nothing wrong with it \u2014 but the deployment, not the manifest, decides where this capability points. Confirm the variable is set to the intended backend in every environment that runs it."
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
if (tool.effect === "irreversible") {
|
|
682
|
+
add({
|
|
683
|
+
severity: "advisory",
|
|
684
|
+
code: "irreversible-effect",
|
|
685
|
+
capability: tool.id,
|
|
686
|
+
message: "is declared `irreversible`",
|
|
687
|
+
because: (
|
|
688
|
+
// #125 (ADD-124 D-12) appends the last sentence — code and severity unchanged. Without
|
|
689
|
+
// it, this advisory and the contract advisory above land on the same capability saying
|
|
690
|
+
// opposite things ("never auto-retry" vs "wire it into CI"). Naming `verify`'s default
|
|
691
|
+
// here is what makes the two agree wherever a reader starts.
|
|
692
|
+
"No API description states this, so it was a human judgement: an agent must confirm explicitly and must never auto-retry. Re-read it before go-live \u2014 `irreversible` is the difference between looking up a price and charging a card. `archstone verify` applies the same judgement: it will not replay this capability's fixture against the live backend unless you assert a sandbox with --sandbox."
|
|
693
|
+
)
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
if (tool.policyRules?.some((r) => r.rateLimit !== void 0)) {
|
|
697
|
+
add({
|
|
698
|
+
severity: "advisory",
|
|
699
|
+
code: "ratelimit-needs-counter",
|
|
700
|
+
capability: tool.id,
|
|
701
|
+
message: "declares a rate limit, which needs a counter supplied at runtime",
|
|
702
|
+
because: "With no counter the call is denied, fail-closed, at the first invocation. On more than one instance the counter must be shared, or a declared 100/min becomes 100/min per instance."
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
if (tool.policies?.includes("authenticated")) {
|
|
706
|
+
add({
|
|
707
|
+
severity: "advisory",
|
|
708
|
+
code: "authenticated-needs-principal",
|
|
709
|
+
capability: tool.id,
|
|
710
|
+
message: "requires an authenticated caller",
|
|
711
|
+
because: "The surface serving it must carry a per-request principal \u2014 `resolveCaller` on HTTP or the embedded SDK. `archstone serve` (stdio) has one static caller for the whole process, so it cannot serve this capability to more than one identity."
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
if (tool.lifecycle === "experimental") {
|
|
715
|
+
add({
|
|
716
|
+
severity: "advisory",
|
|
717
|
+
code: "experimental-capability",
|
|
718
|
+
capability: tool.id,
|
|
719
|
+
message: "is `experimental`: hidden from tool listings but still invocable by id",
|
|
720
|
+
because: "Deliberate behaviour, and easy to forget: an agent that knows the id can still call it. Confirm that is what you want in production."
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
if (opts.builtIr !== void 0) {
|
|
725
|
+
const committed = join2(manifestDir, "archstone.ir.json");
|
|
726
|
+
if (existsSync2(committed)) {
|
|
727
|
+
const onDisk = readFileSync3(committed, "utf8");
|
|
728
|
+
if (onDisk.trim() !== opts.builtIr.trim()) {
|
|
729
|
+
add({
|
|
730
|
+
severity: "error",
|
|
731
|
+
code: "ir-drift",
|
|
732
|
+
message: "the committed archstone.ir.json does not match a fresh build of this manifest",
|
|
733
|
+
because: "The artifact is what runs. A stale one enforces stale policy and exposes stale tools, silently \u2014 rebuild it and commit the result."
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
return {
|
|
739
|
+
findings,
|
|
740
|
+
checked: ir.tools.length,
|
|
741
|
+
ok: !findings.some((f) => f.severity === "error")
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
var ICON = { error: "\u{1F534}", warning: "\u{1F7E1}", advisory: "\u{1F535}" };
|
|
745
|
+
function formatReport2(report, dir) {
|
|
746
|
+
const lines = [`
|
|
747
|
+
archstone doctor ${dir}
|
|
748
|
+
`];
|
|
749
|
+
if (report.findings.length === 0) {
|
|
750
|
+
lines.push(`\u{1F7E2} ${report.checked} capabilities checked \u2014 nothing to flag.
|
|
751
|
+
`);
|
|
752
|
+
return lines.join("\n");
|
|
753
|
+
}
|
|
754
|
+
const order = ["error", "warning", "advisory"];
|
|
755
|
+
for (const sev of order) {
|
|
756
|
+
for (const f of report.findings.filter((x) => x.severity === sev)) {
|
|
757
|
+
lines.push(`${ICON[sev]} ${f.capability ? `${f.capability} \u2014 ` : ""}${f.message}`);
|
|
758
|
+
lines.push(` ${f.because}`);
|
|
759
|
+
lines.push(` (${f.code})
|
|
760
|
+
`);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
const counts = order.map((s) => `${report.findings.filter((f) => f.severity === s).length} ${s}`).join(" \xB7 ");
|
|
764
|
+
lines.push(`${report.checked} capabilities checked \u2014 ${counts}.
|
|
765
|
+
`);
|
|
766
|
+
return lines.join("\n");
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// src/adopt.ts
|
|
770
|
+
import { readFileSync as readFileSync4, writeFileSync as writeFileSync2, mkdtempSync, cpSync, rmSync } from "fs";
|
|
771
|
+
import { tmpdir } from "os";
|
|
772
|
+
import { join as join3, resolve as resolve2 } from "path";
|
|
773
|
+
import { createInterface as createInterface2 } from "readline/promises";
|
|
774
|
+
import { load } from "@archstone/schema";
|
|
775
|
+
import { compile, diffShape, validateSemantics } from "@archstone/compiler";
|
|
776
|
+
import { Registry } from "@archstone/emitter-support";
|
|
777
|
+
import { recordContract, adoptable, planAdoption } from "@archstone/runtime";
|
|
778
|
+
|
|
779
|
+
// src/adopt-edit.ts
|
|
780
|
+
import { yamlKey, yamlScalar } from "@archstone/init";
|
|
781
|
+
function indentOf(line) {
|
|
782
|
+
return line.length - line.trimStart().length;
|
|
783
|
+
}
|
|
784
|
+
function locate(lines, from, to, key) {
|
|
785
|
+
const header = new RegExp(`^(\\s*)${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:\\s*(#.*)?$`);
|
|
786
|
+
const hits = [];
|
|
787
|
+
for (let i = from; i < to; i++) if (header.test(lines[i])) hits.push(i);
|
|
788
|
+
if (hits.length === 0) return { problem: `could not find a '${key}:' block to append to` };
|
|
789
|
+
if (hits.length > 1) return { problem: `found ${hits.length} '${key}:' blocks; refusing to guess which one to extend` };
|
|
790
|
+
const start = hits[0];
|
|
791
|
+
const own = indentOf(lines[start]);
|
|
792
|
+
let end = to;
|
|
793
|
+
for (let i = start + 1; i < to; i++) {
|
|
794
|
+
if (lines[i].trim() === "") continue;
|
|
795
|
+
if (indentOf(lines[i]) <= own) {
|
|
796
|
+
end = i;
|
|
797
|
+
break;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
while (end > start + 1 && lines[end - 1].trim() === "") end--;
|
|
801
|
+
let indent = " ".repeat(own + 2);
|
|
802
|
+
for (let i = start + 1; i < end; i++) {
|
|
803
|
+
if (lines[i].trim() === "") continue;
|
|
804
|
+
indent = lines[i].slice(0, indentOf(lines[i]));
|
|
805
|
+
break;
|
|
806
|
+
}
|
|
807
|
+
return { end, indent };
|
|
808
|
+
}
|
|
809
|
+
function nest(lines, path) {
|
|
810
|
+
let from = 0;
|
|
811
|
+
let to = lines.length;
|
|
812
|
+
let block = { problem: "empty path" };
|
|
813
|
+
for (const key of path) {
|
|
814
|
+
block = locate(lines, from, to, key);
|
|
815
|
+
if ("problem" in block) return block;
|
|
816
|
+
to = block.end;
|
|
817
|
+
for (let i = from; i < to; i++) {
|
|
818
|
+
const header = new RegExp(`^\\s*${key}:\\s*(#.*)?$`);
|
|
819
|
+
if (header.test(lines[i])) {
|
|
820
|
+
from = i + 1;
|
|
821
|
+
break;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
return block;
|
|
826
|
+
}
|
|
827
|
+
function insert(lines, at, added) {
|
|
828
|
+
return [...lines.slice(0, at), ...added, ...lines.slice(at)];
|
|
829
|
+
}
|
|
830
|
+
function applyAdoption(resourceYaml, bindingYaml, edits) {
|
|
831
|
+
if (edits.length === 0) return { ok: true, resource: resourceYaml, binding: bindingYaml };
|
|
832
|
+
let resourceLines = resourceYaml.split("\n");
|
|
833
|
+
const fields = nest(resourceLines, ["resource", "fields"]);
|
|
834
|
+
if ("problem" in fields) return { ok: false, problem: `resource: ${fields.problem}` };
|
|
835
|
+
const added = [];
|
|
836
|
+
for (const e of edits) {
|
|
837
|
+
added.push(
|
|
838
|
+
`${fields.indent}${yamlKey(e.field)}:`,
|
|
839
|
+
`${fields.indent} type: ${yamlScalar(e.semantic)}`,
|
|
840
|
+
`${fields.indent} required: false`,
|
|
841
|
+
`${fields.indent} description: ${yamlScalar(e.description)}`
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
resourceLines = insert(resourceLines, fields.end, added);
|
|
845
|
+
let bindingLines = bindingYaml.split("\n");
|
|
846
|
+
const map = nest(bindingLines, ["binding", "response", "map"]);
|
|
847
|
+
if ("problem" in map) return { ok: false, problem: `binding: ${map.problem}` };
|
|
848
|
+
bindingLines = insert(
|
|
849
|
+
bindingLines,
|
|
850
|
+
map.end,
|
|
851
|
+
edits.map((e) => `${map.indent}${yamlKey(e.field)}: ${yamlScalar(e.itemPath)}`)
|
|
852
|
+
);
|
|
853
|
+
return { ok: true, resource: resourceLines.join("\n"), binding: bindingLines.join("\n") };
|
|
854
|
+
}
|
|
855
|
+
function applyContractRecording(bindingYaml, fingerprint, shape) {
|
|
856
|
+
const lines = bindingYaml.split("\n");
|
|
857
|
+
const fpIdx = lines.findIndex((l) => /^\s*fingerprint:\s/.test(l));
|
|
858
|
+
if (fpIdx === -1) return { ok: false, problem: "binding: no contract fingerprint to update" };
|
|
859
|
+
const indent = lines[fpIdx].slice(0, indentOf(lines[fpIdx]));
|
|
860
|
+
const rendered = [
|
|
861
|
+
`${indent}fingerprint: ${yamlScalar(fingerprint)}`,
|
|
862
|
+
`${indent}shape:`,
|
|
863
|
+
// Sorted, so re-adopting against an unchanged backend is a no-op diff rather than a
|
|
864
|
+
// reshuffle a reviewer has to read.
|
|
865
|
+
...Object.keys(shape).sort().map((k) => `${indent} ${yamlKey(k)}: ${yamlScalar(shape[k])}`)
|
|
866
|
+
];
|
|
867
|
+
const removeFrom = fpIdx;
|
|
868
|
+
let removeTo = fpIdx + 1;
|
|
869
|
+
const shapeIdx = lines.findIndex((l, i) => i > fpIdx && /^\s*shape:\s*$/.test(l));
|
|
870
|
+
if (shapeIdx !== -1 && lines.slice(fpIdx + 1, shapeIdx).every((l) => l.trim() === "" || l.trim().startsWith("#"))) {
|
|
871
|
+
removeTo = shapeIdx + 1;
|
|
872
|
+
const own = indentOf(lines[shapeIdx]);
|
|
873
|
+
while (removeTo < lines.length && (lines[removeTo].trim() === "" || indentOf(lines[removeTo]) > own)) removeTo++;
|
|
874
|
+
const preserved = lines.slice(fpIdx + 1, shapeIdx);
|
|
875
|
+
return {
|
|
876
|
+
ok: true,
|
|
877
|
+
binding: [...lines.slice(0, removeFrom), rendered[0], ...preserved, ...rendered.slice(1), ...lines.slice(removeTo)].join("\n")
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
return { ok: true, binding: [...lines.slice(0, fpIdx), ...rendered, ...lines.slice(fpIdx + 1)].join("\n") };
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// src/adopt.ts
|
|
884
|
+
function locateFiles(dir, tool) {
|
|
885
|
+
const res = load(dir);
|
|
886
|
+
const wanted = tool.response?.resource;
|
|
887
|
+
if (!wanted) return { problem: `${tool.id}: no response mapping \u2014 nothing to adopt into` };
|
|
888
|
+
const bare = wanted.includes(".") ? wanted.slice(wanted.lastIndexOf(".") + 1) : wanted;
|
|
889
|
+
const doc = res.resourceDocs.find((d) => d.resource.name === wanted || d.resource.name.endsWith(`.${bare}`) || d.resource.name === bare);
|
|
890
|
+
if (!doc) return { problem: `${tool.id}: could not find the file declaring resource '${wanted}'` };
|
|
891
|
+
const binding = res.bindings.find((b) => b.binding.capabilityId === tool.id);
|
|
892
|
+
if (!binding) return { problem: `${tool.id}: could not find its binding file` };
|
|
893
|
+
return { tool, resourceFile: join3(dir, doc.file), bindingFile: join3(dir, binding.file) };
|
|
894
|
+
}
|
|
895
|
+
function readFixture(dir, path) {
|
|
896
|
+
try {
|
|
897
|
+
return JSON.parse(readFileSync4(resolve2(dir, path), "utf8"));
|
|
898
|
+
} catch {
|
|
899
|
+
return void 0;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
async function confirm2(ask, question) {
|
|
903
|
+
const answer = (await ask.question(`${question} [y/N] `)).trim().toLowerCase();
|
|
904
|
+
return answer === "y" || answer === "yes";
|
|
905
|
+
}
|
|
906
|
+
function compilesClean(dir, resourceFile, resourceYaml, bindingFile, bindingYaml) {
|
|
907
|
+
const scratch = mkdtempSync(join3(tmpdir(), "archstone-adopt-"));
|
|
908
|
+
try {
|
|
909
|
+
cpSync(dir, scratch, { recursive: true });
|
|
910
|
+
writeFileSync2(join3(scratch, resourceFile.slice(dir.length + 1)), resourceYaml);
|
|
911
|
+
writeFileSync2(join3(scratch, bindingFile.slice(dir.length + 1)), bindingYaml);
|
|
912
|
+
const res = load(scratch);
|
|
913
|
+
if (!res.ok) return res.issues.map((i) => `${i.file}: ${i.message}`).join("; ");
|
|
914
|
+
const errors = validateSemantics(res).filter((d) => d.severity === "error");
|
|
915
|
+
if (errors.length > 0) return errors.map((e) => e.message).join("; ");
|
|
916
|
+
compile(res);
|
|
917
|
+
return void 0;
|
|
918
|
+
} catch (err) {
|
|
919
|
+
return err.message;
|
|
920
|
+
} finally {
|
|
921
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
async function adoptOne(dir, target, contractShape, ask) {
|
|
925
|
+
const { tool } = target;
|
|
926
|
+
const fixture = readFixture(dir, tool.contract.probeFixture);
|
|
927
|
+
if (!fixture) {
|
|
928
|
+
console.error(` ${tool.id}: fixture not found or unreadable \u2014 nothing to replay`);
|
|
929
|
+
return 1;
|
|
930
|
+
}
|
|
931
|
+
const recording = await recordContract(tool, fixture.request, {}, {});
|
|
932
|
+
if (recording.outcome !== "green" && recording.outcome !== "yellow") {
|
|
933
|
+
console.error(` ${tool.id}: ${recording.detail}`);
|
|
934
|
+
return 1;
|
|
935
|
+
}
|
|
936
|
+
const liveShape = recording.shape ?? {};
|
|
937
|
+
const drift = diffShape(contractShape ?? {}, liveShape);
|
|
938
|
+
const plan = planAdoption(tool, drift, new Registry(compile(load(dir))).ir.resources);
|
|
939
|
+
const offers = adoptable(plan);
|
|
940
|
+
const refused = plan.candidates.filter((c) => !c.adoptable);
|
|
941
|
+
if (refused.length > 0) {
|
|
942
|
+
console.log(`
|
|
943
|
+
${tool.id} \u2014 not adoptable:`);
|
|
944
|
+
for (const c of refused) if (!c.adoptable) console.log(` \xB7 ${c.path} (${c.observed}) \u2014 ${c.detail}`);
|
|
945
|
+
}
|
|
946
|
+
if (offers.length === 0) {
|
|
947
|
+
console.log(`
|
|
948
|
+
${tool.id} \u2014 nothing to adopt.`);
|
|
949
|
+
return 0;
|
|
950
|
+
}
|
|
951
|
+
console.log(`
|
|
952
|
+
${tool.id} \u2014 ${offers.length} field(s) the backend returns and the manifest does not declare:`);
|
|
953
|
+
for (const o of offers) console.log(` \xB7 ${o.path} (${o.observed}) \u2192 ${o.field}: ${o.semantic}`);
|
|
954
|
+
console.log("");
|
|
955
|
+
const edits = [];
|
|
956
|
+
for (const o of offers) {
|
|
957
|
+
if (!await confirm2(ask, ` Declare ${o.field} (${o.semantic})?`)) continue;
|
|
958
|
+
const description = (await ask.question(` Describe ${o.field} \u2014 an agent reads this to decide whether to use it:
|
|
959
|
+
> `)).trim();
|
|
960
|
+
if (description === "") {
|
|
961
|
+
console.log(` ${o.field}: no description given \u2014 not adopted.`);
|
|
962
|
+
continue;
|
|
963
|
+
}
|
|
964
|
+
edits.push({ field: o.field, itemPath: o.itemPath, semantic: o.semantic, description });
|
|
965
|
+
}
|
|
966
|
+
if (edits.length === 0) {
|
|
967
|
+
console.log(`
|
|
968
|
+
${tool.id} \u2014 nothing adopted.`);
|
|
969
|
+
return 0;
|
|
970
|
+
}
|
|
971
|
+
const resourceYaml = readFileSync4(target.resourceFile, "utf8");
|
|
972
|
+
const bindingYaml = readFileSync4(target.bindingFile, "utf8");
|
|
973
|
+
const applied = applyAdoption(resourceYaml, bindingYaml, edits);
|
|
974
|
+
if (!applied.ok) {
|
|
975
|
+
console.error(` ${tool.id}: ${applied.problem}`);
|
|
976
|
+
return 1;
|
|
977
|
+
}
|
|
978
|
+
const rewritten = applyContractRecording(applied.binding, recording.fingerprint, liveShape);
|
|
979
|
+
if (!rewritten.ok) {
|
|
980
|
+
console.error(` ${tool.id}: ${rewritten.problem}`);
|
|
981
|
+
return 1;
|
|
982
|
+
}
|
|
983
|
+
const problem = compilesClean(dir, target.resourceFile, applied.resource, target.bindingFile, rewritten.binding);
|
|
984
|
+
if (problem) {
|
|
985
|
+
console.error(`
|
|
986
|
+
${tool.id}: the edit does not compile \u2014 nothing written.
|
|
987
|
+
${problem}`);
|
|
988
|
+
return 1;
|
|
989
|
+
}
|
|
990
|
+
writeFileSync2(target.resourceFile, applied.resource);
|
|
991
|
+
writeFileSync2(target.bindingFile, rewritten.binding);
|
|
992
|
+
console.log(`
|
|
993
|
+
${tool.id} \u2014 declared ${edits.map((e) => e.field).join(", ")}; contract re-recorded.`);
|
|
994
|
+
return 0;
|
|
995
|
+
}
|
|
996
|
+
async function runAdoptCmd(argv) {
|
|
997
|
+
const dir = argv.find((a, i) => i > 0 && !a.startsWith("-"));
|
|
998
|
+
if (!dir) {
|
|
999
|
+
console.error("usage: archstone adopt <manifest-dir>");
|
|
1000
|
+
return 2;
|
|
1001
|
+
}
|
|
1002
|
+
const res = load(dir);
|
|
1003
|
+
const errors = validateSemantics(res).filter((d) => d.severity === "error");
|
|
1004
|
+
if (!res.ok || errors.length > 0) {
|
|
1005
|
+
console.error(`archstone adopt ${dir}: manifest invalid \u2014 run 'archstone apply ${dir}' for details`);
|
|
1006
|
+
return 2;
|
|
1007
|
+
}
|
|
1008
|
+
const registry = new Registry(compile(res));
|
|
1009
|
+
const targets = registry.listCapabilities().filter((t) => t.contract);
|
|
1010
|
+
if (targets.length === 0) {
|
|
1011
|
+
console.log(`
|
|
1012
|
+
archstone adopt ${dir}
|
|
1013
|
+
|
|
1014
|
+
(no bindings declare a contract: \u2014 nothing to probe)
|
|
1015
|
+
`);
|
|
1016
|
+
return 0;
|
|
1017
|
+
}
|
|
1018
|
+
console.log(`
|
|
1019
|
+
archstone adopt ${dir}`);
|
|
1020
|
+
const rl = createInterface2({ input: process.stdin, output: process.stdout });
|
|
1021
|
+
const ask = terminalAsk(rl);
|
|
1022
|
+
let worst = 0;
|
|
1023
|
+
try {
|
|
1024
|
+
for (const tool of targets) {
|
|
1025
|
+
const located = locateFiles(dir, tool);
|
|
1026
|
+
if ("problem" in located) {
|
|
1027
|
+
console.error(` ${located.problem}`);
|
|
1028
|
+
worst = Math.max(worst, 1);
|
|
1029
|
+
continue;
|
|
1030
|
+
}
|
|
1031
|
+
worst = Math.max(worst, await adoptOne(dir, located, tool.contract.shape, ask));
|
|
1032
|
+
}
|
|
1033
|
+
} catch (err) {
|
|
1034
|
+
console.error(`
|
|
1035
|
+
archstone adopt: no more input \u2014 nothing written. Adoption needs a person (${err.name}).`);
|
|
1036
|
+
return 1;
|
|
1037
|
+
} finally {
|
|
1038
|
+
rl.close();
|
|
1039
|
+
}
|
|
1040
|
+
console.log("");
|
|
1041
|
+
return worst;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
599
1044
|
// src/index.ts
|
|
600
1045
|
function cliVersion() {
|
|
601
1046
|
try {
|
|
@@ -610,11 +1055,11 @@ function printUsage(opts) {
|
|
|
610
1055
|
// `init` is named HERE, in the verb list, and not only in the block below it. It takes a
|
|
611
1056
|
// spec file rather than a manifest directory, so it cannot share the first line's shape —
|
|
612
1057
|
// which is exactly how it came to be missing from the one line a user actually scans.
|
|
613
|
-
"usage: archstone <apply|serve|verify|build|init|audit>\n\n archstone <apply|serve|verify|build> <manifest-dir> [--json] [--out path]\n archstone serve --http <manifest-dir> [--port <n>] [--token <value>]\n bearer token: --token <value>, or the ARCHSTONE_HTTP_TOKEN env var (required \u2014 never serves open)\n archstone init <spec-file> --out <dir> \u2014 start here if you have no manifest yet\n archstone audit <file...> [--since <date>] [--format summary|jsonl|csv]\n read your own Execution audit records; nothing is uploaded (audit --help for filters)\n\n archstone --version | --help\n\n" + INIT_USAGE
|
|
1058
|
+
"usage: archstone <apply|serve|verify|build|doctor|init|adopt|audit>\n\n archstone <apply|serve|verify|build> <manifest-dir> [--json] [--out path]\n archstone verify <manifest-dir> [--json] [--sandbox]\n --sandbox: also replay `write`/`irreversible` fixtures \u2014 they are skipped by default,\n because a replay is a real invocation. Only for a backend you know is a sandbox tenant.\n archstone serve --http <manifest-dir> [--port <n>] [--token <value>]\n bearer token: --token <value>, or the ARCHSTONE_HTTP_TOKEN env var (required \u2014 never serves open)\n archstone doctor <manifest-dir> [--json] \u2014 pre-production checks, offline\n archstone init <spec-file> --out <dir> \u2014 start here if you have no manifest yet\n archstone adopt <manifest-dir>\n declare a field the backend started returning; asks before writing, needs a person\n\n archstone audit <file...> [--since <date>] [--format summary|jsonl|csv]\n read your own Execution audit records; nothing is uploaded (audit --help for filters)\n\n archstone --version | --help\n\n" + INIT_USAGE
|
|
614
1059
|
);
|
|
615
1060
|
}
|
|
616
1061
|
function runApply(dir) {
|
|
617
|
-
const res =
|
|
1062
|
+
const res = load2(dir);
|
|
618
1063
|
console.log(`
|
|
619
1064
|
archstone apply ${dir}
|
|
620
1065
|
`);
|
|
@@ -643,14 +1088,14 @@ archstone apply ${dir}
|
|
|
643
1088
|
console.log(`
|
|
644
1089
|
\u2713 shapes valid`);
|
|
645
1090
|
}
|
|
646
|
-
const diags =
|
|
1091
|
+
const diags = validateSemantics2(res);
|
|
647
1092
|
const errors = diags.filter((d) => d.severity === "error");
|
|
648
1093
|
const warnings = diags.filter((d) => d.severity === "warning");
|
|
649
1094
|
console.log(` semantic ${errors.length} error(s), ${warnings.length} warning(s)`);
|
|
650
1095
|
for (const d of errors) console.log(` \u2717 ${d.message}`);
|
|
651
1096
|
for (const d of warnings) console.log(` \u26A0 ${d.message}`);
|
|
652
1097
|
const shapesAndSemanticsOk = res.ok && errors.length === 0;
|
|
653
|
-
const registry = shapesAndSemanticsOk ? new
|
|
1098
|
+
const registry = shapesAndSemanticsOk ? new Registry2(compile2(res)) : void 0;
|
|
654
1099
|
const collisions = registry?.toolNameCollisions ?? [];
|
|
655
1100
|
if (collisions.length > 0) {
|
|
656
1101
|
console.log(`
|
|
@@ -670,8 +1115,8 @@ archstone apply ${dir}
|
|
|
670
1115
|
process.exit(ok ? 0 : 1);
|
|
671
1116
|
}
|
|
672
1117
|
function runBuild(dir, outPath) {
|
|
673
|
-
const res =
|
|
674
|
-
const diags =
|
|
1118
|
+
const res = load2(dir);
|
|
1119
|
+
const diags = validateSemantics2(res);
|
|
675
1120
|
const errors = diags.filter((d) => d.severity === "error");
|
|
676
1121
|
const ok = res.ok && errors.length === 0;
|
|
677
1122
|
if (!ok) {
|
|
@@ -680,8 +1125,8 @@ function runBuild(dir, outPath) {
|
|
|
680
1125
|
for (const d of errors) console.error(` - ${d.message}`);
|
|
681
1126
|
process.exit(1);
|
|
682
1127
|
}
|
|
683
|
-
const ir =
|
|
684
|
-
const registry = new
|
|
1128
|
+
const ir = compile2(res);
|
|
1129
|
+
const registry = new Registry2(ir);
|
|
685
1130
|
if (registry.toolNameCollisions.length > 0) {
|
|
686
1131
|
console.error(`archstone build ${dir}: refusing to write artifact \u2014 tool-name collision(s):`);
|
|
687
1132
|
for (const c of registry.toolNameCollisions) {
|
|
@@ -690,8 +1135,8 @@ function runBuild(dir, outPath) {
|
|
|
690
1135
|
process.exit(1);
|
|
691
1136
|
}
|
|
692
1137
|
const stripped = { ...ir, tools: ir.tools.map(({ contract: _contract, ...t }) => t) };
|
|
693
|
-
const outFile =
|
|
694
|
-
|
|
1138
|
+
const outFile = resolve3(process.cwd(), outPath ?? "archstone.ir.json");
|
|
1139
|
+
writeFileSync3(outFile, `${JSON.stringify(stripped, null, 2)}
|
|
695
1140
|
`);
|
|
696
1141
|
console.log(`archstone build ${dir} \u2192 ${outFile} (${stripped.tools.length} tool(s))`);
|
|
697
1142
|
process.exit(0);
|
|
@@ -782,9 +1227,11 @@ async function handleHttpRequest(handler, req, res) {
|
|
|
782
1227
|
}
|
|
783
1228
|
}
|
|
784
1229
|
var HEALTH_ICON = { green: "\u{1F7E2}", yellow: "\u{1F7E1}", red: "\u{1F534}" };
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
1230
|
+
var SKIP_ICON = "\u23ED";
|
|
1231
|
+
var READ_TWIN_TIP = " Where one of these has a `read` capability against the same backend \u2014 the quote half of a\n quote \u2192 commit pair \u2014 verifying that instead hits the same host, auth and serialization,\n catching most infrastructure and schema drift at zero risk. Not every write has one, and\n Archstone cannot tell you which capability it is: nothing in CDL declares that relationship.\n If this backend really is a sandbox tenant, pass --sandbox.";
|
|
1232
|
+
async function runVerifyCmd(dir, json, sandbox) {
|
|
1233
|
+
const res = load2(dir);
|
|
1234
|
+
const diags = validateSemantics2(res);
|
|
788
1235
|
const errors = diags.filter((d) => d.severity === "error");
|
|
789
1236
|
const ok = res.ok && errors.length === 0;
|
|
790
1237
|
if (!ok) {
|
|
@@ -795,29 +1242,53 @@ async function runVerifyCmd(dir, json) {
|
|
|
795
1242
|
}
|
|
796
1243
|
process.exit(2);
|
|
797
1244
|
}
|
|
798
|
-
const registry = new
|
|
799
|
-
const
|
|
1245
|
+
const registry = new Registry2(compile2(res));
|
|
1246
|
+
const { results, skipped } = sandbox ? await runVerify(registry.listCapabilities(), dir, registry.ir.resources, void 0, { includeNonRead: true }) : await runVerify(registry.listCapabilities(), dir, registry.ir.resources);
|
|
1247
|
+
const exitCode = results.some((r) => r.status === "red") ? 1 : 0;
|
|
800
1248
|
if (json) {
|
|
801
|
-
console.log(JSON.stringify({ results
|
|
802
|
-
process.exit(
|
|
1249
|
+
console.log(JSON.stringify({ results, skipped, sandbox }));
|
|
1250
|
+
process.exit(exitCode);
|
|
803
1251
|
}
|
|
804
1252
|
console.log(`
|
|
805
1253
|
archstone verify ${dir}
|
|
806
1254
|
`);
|
|
807
|
-
if (
|
|
1255
|
+
if (results.length === 0 && skipped.length === 0) {
|
|
808
1256
|
console.log(" (no bindings declare a contract: \u2014 nothing to verify)\n");
|
|
809
1257
|
process.exit(0);
|
|
810
1258
|
}
|
|
811
|
-
for (const r of
|
|
1259
|
+
for (const r of results) {
|
|
812
1260
|
console.log(` ${HEALTH_ICON[r.status]} ${r.capabilityId} \u2014 ${r.detail}`);
|
|
813
1261
|
}
|
|
1262
|
+
for (const s of skipped) {
|
|
1263
|
+
console.log(` ${SKIP_ICON} ${s.capabilityId} \u2014 ${s.detail}`);
|
|
1264
|
+
}
|
|
1265
|
+
if (skipped.length > 0) {
|
|
1266
|
+
console.log(`
|
|
1267
|
+
${skipped.length} binding(s) were NOT verified against the backend.`);
|
|
1268
|
+
console.log(READ_TWIN_TIP);
|
|
1269
|
+
}
|
|
814
1270
|
console.log("");
|
|
815
|
-
process.exit(
|
|
1271
|
+
process.exit(exitCode);
|
|
816
1272
|
}
|
|
817
1273
|
function flagArg(argv, name) {
|
|
818
1274
|
const idx = argv.indexOf(name);
|
|
819
1275
|
return { value: idx !== -1 ? argv[idx + 1] : void 0, idx };
|
|
820
1276
|
}
|
|
1277
|
+
function runDoctor(dir, json) {
|
|
1278
|
+
const res = load2(dir);
|
|
1279
|
+
const diags = validateSemantics2(res);
|
|
1280
|
+
const errors = diags.filter((d) => d.severity === "error");
|
|
1281
|
+
if (!res.ok || errors.length > 0) {
|
|
1282
|
+
console.error(`archstone doctor ${dir}: manifest invalid \u2014 run 'archstone apply ${dir}' for details`);
|
|
1283
|
+
process.exit(1);
|
|
1284
|
+
}
|
|
1285
|
+
const ir = compile2(res);
|
|
1286
|
+
const stripped = { ...ir, tools: ir.tools.map(({ contract: _contract, ...t }) => t) };
|
|
1287
|
+
const report = diagnose(ir, dir, { builtIr: `${JSON.stringify(stripped, null, 2)}
|
|
1288
|
+
` });
|
|
1289
|
+
console.log(json ? JSON.stringify(report, null, 2) : formatReport2(report, dir));
|
|
1290
|
+
process.exit(report.ok ? 0 : 1);
|
|
1291
|
+
}
|
|
821
1292
|
async function main() {
|
|
822
1293
|
const argv = process.argv.slice(2);
|
|
823
1294
|
if (argv.includes("--version") || argv.includes("-V")) {
|
|
@@ -830,6 +1301,7 @@ async function main() {
|
|
|
830
1301
|
}
|
|
831
1302
|
const json = argv.includes("--json");
|
|
832
1303
|
const http = argv.includes("--http");
|
|
1304
|
+
const sandbox = argv.includes("--sandbox");
|
|
833
1305
|
const out = flagArg(argv, "--out");
|
|
834
1306
|
const port = flagArg(argv, "--port");
|
|
835
1307
|
const token = flagArg(argv, "--token");
|
|
@@ -840,7 +1312,7 @@ async function main() {
|
|
|
840
1312
|
consumed.add(f.idx + 1);
|
|
841
1313
|
}
|
|
842
1314
|
}
|
|
843
|
-
const positional = argv.filter((a, i) => !consumed.has(i) && a !== "--json" && a !== "--http");
|
|
1315
|
+
const positional = argv.filter((a, i) => !consumed.has(i) && a !== "--json" && a !== "--http" && a !== "--sandbox");
|
|
844
1316
|
const [cmd, dir] = positional;
|
|
845
1317
|
if (cmd === "apply" && dir) {
|
|
846
1318
|
runApply(dir);
|
|
@@ -855,13 +1327,20 @@ async function main() {
|
|
|
855
1327
|
return;
|
|
856
1328
|
}
|
|
857
1329
|
if (cmd === "verify" && dir) {
|
|
858
|
-
await runVerifyCmd(dir, json);
|
|
1330
|
+
await runVerifyCmd(dir, json, sandbox);
|
|
859
1331
|
return;
|
|
860
1332
|
}
|
|
861
1333
|
if (cmd === "build" && dir) {
|
|
862
1334
|
runBuild(dir, out.value);
|
|
863
1335
|
return;
|
|
864
1336
|
}
|
|
1337
|
+
if (cmd === "doctor" && dir) {
|
|
1338
|
+
runDoctor(dir, json);
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
if (cmd === "adopt") {
|
|
1342
|
+
process.exit(await runAdoptCmd(argv));
|
|
1343
|
+
}
|
|
865
1344
|
if (cmd === "audit") {
|
|
866
1345
|
process.exit(runAuditCmd(argv));
|
|
867
1346
|
}
|