@nexart/cli 0.6.0 → 0.8.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 CHANGED
@@ -1,27 +1,58 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * @nexart/cli v0.6.0 — NexArt CLI
3
+ * @nexart/cli v0.8.0 — NexArt CLI
4
4
  *
5
5
  * Code Mode commands:
6
- * - nexart run <file> — Execute via remote renderer and create snapshot
6
+ * - nexart run <file> — Execute via remote renderer and create snapshot
7
7
  * - nexart replay <snapshot> — Re-execute from snapshot
8
8
  * - nexart verify <snapshot> — Verify snapshot hash
9
9
  *
10
10
  * AI Certification commands:
11
- * - nexart ai create <file> — Create a CER bundle via node API
12
- * - nexart ai certify <file> — Certify an AI execution and attest
13
- * - nexart ai verify <file> Verify a CER bundle or CER package locally (signals-aware)
11
+ * - nexart ai create <file> — Create a CER bundle via node API
12
+ * - nexart ai certify <file> — Certify an AI execution and attest
13
+ * - nexart ai seal <file> Seal a CER bundle locally (offline, no node)
14
+ * - nexart ai verify <file> — Verify a CER bundle or CER package locally
15
+ * - nexart ai project-verify <file> — Verify a cer.project.bundle.v1 artifact locally
14
16
  *
15
- * Context signals (v0.5.0+):
17
+ * Sealed vs Certified:
18
+ * - Sealed = integrity only. Bundle is locally produced via SDK sealCer().
19
+ * No node receipt, no signature, no envelope. Use for offline
20
+ * workflows, CI, or pre-certification staging. `ai seal` builds
21
+ * these.
22
+ * - Certified = integrity + node attestation receipt. Bundle has been
23
+ * attested by a NexArt node and includes a signed receipt.
24
+ * `ai certify` builds these.
25
+ * `ai verify` works on both. For sealed bundles, the Receipt and Envelope
26
+ * layers report SKIPPED — that is correct, not a failure.
27
+ *
28
+ * VERIFICATION ALIGNMENT (v0.8.0):
29
+ * `ai verify` and `ai project-verify` both delegate ALL verification logic to
30
+ * @nexart/ai-execution (>=0.16.1). The CLI implements ZERO independent
31
+ * cryptographic logic — no canonicalization, no hash recomputation, no
32
+ * integrity checks. This guarantees byte-for-byte parity with:
33
+ * - the SDK (`verifyAiCerBundleDetailed`, `verifyProjectBundle`)
34
+ * - NexArt Node (`/v1/cer/verify`)
35
+ * - verify.nexart.io
36
+ *
37
+ * `ai verify` reports the three SDK protocol layers independently:
38
+ * - bundleIntegrity (Integrity: certificate hash recomputation)
39
+ * - nodeSignature (Receipt: node attestation signature validity)
40
+ * - receiptConsistency (Envelope: bundle/receipt envelope consistency)
41
+ * Each layer is reported as PASS / FAIL / SKIPPED with no aggregation, and
42
+ * the SDK reason codes are passed through unmodified.
43
+ *
44
+ * Context signals:
16
45
  * Pass --signals-file <path> to ai create / ai certify to attach upstream
17
- * context signals to the CER. Signals are evidence-only and are included in
18
- * the certificateHash. ai verify handles signals-containing bundles correctly.
46
+ * context signals to the CER. Signals are evidence and are bound by the
47
+ * SDK into the certificateHash. ai verify validates them via the SDK.
19
48
  *
20
- * CER packages (v0.6.0+):
21
- * nexart ai verify accepts both raw CER bundles and CER packages.
22
- * A CER package is { cer: <bundle>, receipt?, signature?, ... }.
23
- * Only the inner cer bundle is verified. Package-level receipt/signature/
24
- * envelope fields are not verified by this command.
49
+ * CER packages:
50
+ * ai verify accepts raw CER bundles and CER packages. For packages, the
51
+ * inner CER bundle is unwrapped and passed to the SDK verifier.
52
+ *
53
+ * Project bundles:
54
+ * ai project-verify delegates to verifyProjectBundle() from @nexart/ai-execution.
55
+ * No network required. The CLI provides command surface and output formatting only.
25
56
  */
26
57
  import yargs from 'yargs';
27
58
  import { hideBin } from 'yargs/helpers';
@@ -32,12 +63,44 @@ import * as http from 'http';
32
63
  import * as https from 'https';
33
64
  import { fileURLToPath } from 'url';
34
65
  import { realpathSync } from 'fs';
35
- const CLI_VERSION = '0.6.0';
36
- const SDK_VERSION = '1.10.1';
66
+ import { createRequire } from 'module';
67
+ import { verifyProjectBundle, verifyAiCerBundleDetailed, hasAttestation as sdkHasAttestation, isCerPackage as sdkIsCerPackage, getCerFromPackage, createSnapshot as sdkCreateSnapshot, sealCer as sdkSealCer, } from '@nexart/ai-execution';
68
+ const CLI_VERSION = '0.8.0';
69
+ /**
70
+ * Code Mode renderer protocol version. This is the wire-protocol version the
71
+ * CLI sends to the remote renderer for snapshot creation. It is unrelated to
72
+ * the AI CER protocol (which is owned and versioned by @nexart/ai-execution).
73
+ */
37
74
  const PROTOCOL_VERSION = '1.2.0';
75
+ /**
76
+ * Stable fallback identifier used when computing a Code Mode `runtimeHash`
77
+ * locally (e.g. when the remote renderer doesn't supply one). This is NOT a
78
+ * CER hash and is NOT part of any verification path — it just needs to be
79
+ * stable across replays of the same CLI version.
80
+ */
81
+ const CODE_MODE_RUNTIME_TAG = `nexart-cli/${CLI_VERSION}`;
82
+ /**
83
+ * Read the installed @nexart/ai-execution version at runtime from its
84
+ * package.json. Used by --version output so what the CLI reports always
85
+ * matches what is actually loaded.
86
+ */
87
+ function getSdkVersion() {
88
+ try {
89
+ const req = createRequire(import.meta.url);
90
+ const pkg = req('@nexart/ai-execution/package.json');
91
+ return typeof pkg.version === 'string' ? pkg.version : 'unknown';
92
+ }
93
+ catch {
94
+ return 'unknown';
95
+ }
96
+ }
38
97
  const DEFAULT_ENDPOINT = process.env.NEXART_RENDERER_ENDPOINT || 'http://localhost:5000';
39
98
  const DEFAULT_API_KEY = process.env.NEXART_API_KEY || '';
40
99
  const DEFAULT_NODE_ENDPOINT = process.env.NEXART_NODE_ENDPOINT || 'https://node.nexart.art';
100
+ // NOTE: A local `CerBundle` shape used to live here. It has been removed in
101
+ // v0.8.0 — the canonical CER bundle type is `CerAiExecutionBundle` from
102
+ // @nexart/ai-execution, which the CLI imports directly. Keeping a parallel
103
+ // type would risk drift from the SDK schema.
41
104
  function sha256(data) {
42
105
  const buffer = typeof data === 'string' ? Buffer.from(data, 'utf-8') : data;
43
106
  return crypto.createHash('sha256').update(buffer).digest('hex');
@@ -186,7 +249,7 @@ async function callRenderer(endpoint, code, seed, VAR, width, height, apiKey) {
186
249
  return;
187
250
  }
188
251
  if (contentType.includes('image/png')) {
189
- const runtimeHash = res.headers['x-runtime-hash'] || sha256(SDK_VERSION);
252
+ const runtimeHash = res.headers['x-runtime-hash'] || sha256(CODE_MODE_RUNTIME_TAG);
190
253
  resolve({ pngBytes: responseBuffer, runtimeHash });
191
254
  return;
192
255
  }
@@ -198,7 +261,7 @@ async function callRenderer(endpoint, code, seed, VAR, width, height, apiKey) {
198
261
  }
199
262
  if (json.pngBase64) {
200
263
  const pngBytes = Buffer.from(json.pngBase64, 'base64');
201
- const runtimeHash = json.runtimeHash || sha256(SDK_VERSION);
264
+ const runtimeHash = json.runtimeHash || sha256(CODE_MODE_RUNTIME_TAG);
202
265
  resolve({ pngBytes, runtimeHash });
203
266
  return;
204
267
  }
@@ -269,7 +332,7 @@ async function runCommand(file, options) {
269
332
  else {
270
333
  console.log(`[nexart] WARNING: Local renderer NOT implemented yet — outputs placeholder image only`);
271
334
  pngBytes = createPlaceholderPng();
272
- runtimeHash = options.runtimeHash || sha256(SDK_VERSION);
335
+ runtimeHash = options.runtimeHash || sha256(CODE_MODE_RUNTIME_TAG);
273
336
  }
274
337
  const outputHash = sha256(pngBytes);
275
338
  const snapshot = {
@@ -546,48 +609,19 @@ export async function callNodeApi(endpoint, apiPath, body, apiKey, fetchFn = glo
546
609
  return { status: response.status, data };
547
610
  }
548
611
  /**
549
- * Deterministic canonical JSON serialization matching CER v1 spec:
550
- * - Object keys sorted lexicographically
551
- * - Nested objects recursively sorted
552
- * - Arrays maintain insertion order
553
- * - undefined values omitted
554
- */
555
- export function canonicalJson(value) {
556
- if (value === null || typeof value !== 'object') {
557
- return JSON.stringify(value);
558
- }
559
- if (Array.isArray(value)) {
560
- return '[' + value.map(canonicalJson).join(',') + ']';
561
- }
562
- const obj = value;
563
- const keys = Object.keys(obj).sort();
564
- const pairs = keys
565
- .filter(k => obj[k] !== undefined)
566
- .map(k => JSON.stringify(k) + ':' + canonicalJson(obj[k]));
567
- return '{' + pairs.join(',') + '}';
568
- }
569
- /**
570
- * Recompute the certificateHash for a CER v1 bundle.
571
- * Protected set: { bundleType, version, createdAt, snapshot }
572
- * v0.11.0+: context is included in the protected set when present (non-null/non-undefined),
573
- * ensuring signals are tamper-evident. When absent, the hash is identical to pre-v0.11.0.
612
+ * NOTE: As of v0.8.0, the CLI implements ZERO independent cryptographic logic.
613
+ *
614
+ * Removed in v0.8.0 (drift hazard — canonical CER semantics live in the SDK):
615
+ * - canonicalJson() → use @nexart/ai-execution canonicalization
616
+ * - computeBundleHash() → use verifyAiCerBundleDetailed() / verifyCer()
617
+ * - hasAttestation() → re-exported below from @nexart/ai-execution
618
+ *
619
+ * The CLI MUST NOT recompute certificateHash, canonicalize CER JSON, or compare
620
+ * hashes by hand. All such logic is owned by @nexart/ai-execution. This is what
621
+ * guarantees CLI verification matches the SDK, NexArt Node (`/v1/cer/verify`),
622
+ * and verify.nexart.io exactly.
574
623
  */
575
- export function computeBundleHash(bundle) {
576
- const { bundleType, version, createdAt, snapshot } = bundle;
577
- const protectedSet = { bundleType, version, createdAt, snapshot };
578
- const context = bundle['context'];
579
- if (context !== undefined && context !== null) {
580
- protectedSet['context'] = context;
581
- }
582
- return 'sha256:' + sha256(canonicalJson(protectedSet));
583
- }
584
- /**
585
- * Check whether a CER bundle has a valid-looking attestation.
586
- */
587
- export function hasAttestation(bundle) {
588
- return (typeof bundle['attestationId'] === 'string' &&
589
- bundle['attestationId'].length > 0);
590
- }
624
+ export const hasAttestation = sdkHasAttestation;
591
625
  /**
592
626
  * Load a JSON array of context signals from a file path.
593
627
  * Exits with an error message if the file is missing, invalid JSON, or not an array.
@@ -619,6 +653,111 @@ export function loadSignalsFile(signalsPath) {
619
653
  }
620
654
  return parsed;
621
655
  }
656
+ /**
657
+ * Internal: validate that `raw` looks like a CreateSnapshotParams payload and
658
+ * return a typed object (plus optional seal-time inputs). The CLI owns input
659
+ * shape validation only — it does NOT touch crypto or canonicalization. All
660
+ * structural CER semantics belong to the SDK.
661
+ *
662
+ * Required fields: executionId, provider, model, prompt, input, output, parameters
663
+ * Seal-time optionals (top-level on the payload): createdAt, signals, meta, declaration
664
+ */
665
+ function buildSealInputs(raw) {
666
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
667
+ console.error('[nexart] Error: Input must be a JSON object describing an AI execution.');
668
+ process.exit(1);
669
+ }
670
+ const obj = raw;
671
+ const required = ['executionId', 'provider', 'model', 'prompt', 'input', 'output', 'parameters'];
672
+ const missing = required.filter((k) => obj[k] === undefined || obj[k] === null);
673
+ if (missing.length > 0) {
674
+ console.error(`[nexart] Error: Missing required field(s) for ai seal: ${missing.join(', ')}`);
675
+ console.error('[nexart] Required fields: executionId, provider, model, prompt, input, output, parameters');
676
+ process.exit(1);
677
+ }
678
+ if (typeof obj.parameters !== 'object' || obj.parameters === null || Array.isArray(obj.parameters)) {
679
+ console.error('[nexart] Error: `parameters` must be an object (AiExecutionParameters).');
680
+ process.exit(1);
681
+ }
682
+ // Pass the payload through to createSnapshot. The SDK is the schema authority
683
+ // and will throw structured errors on anything it does not accept.
684
+ const snapshotParams = obj;
685
+ const sealOptions = {};
686
+ if (typeof obj.createdAt === 'string')
687
+ sealOptions.createdAt = obj.createdAt;
688
+ if (Array.isArray(obj.signals))
689
+ sealOptions.signals = obj.signals;
690
+ if (obj.meta && typeof obj.meta === 'object' && !Array.isArray(obj.meta)) {
691
+ sealOptions.meta = obj.meta;
692
+ }
693
+ if (obj.declaration && typeof obj.declaration === 'object' && !Array.isArray(obj.declaration)) {
694
+ sealOptions.declaration = obj.declaration;
695
+ }
696
+ return { snapshotParams, sealOptions };
697
+ }
698
+ /**
699
+ * nexart ai seal — Build a Certified Execution Record locally.
700
+ *
701
+ * This command is offline-only. It performs ZERO network I/O and ZERO local
702
+ * cryptography: all snapshot construction, canonicalization, and certificate
703
+ * hashing are delegated to @nexart/ai-execution via createSnapshot() and
704
+ * sealCer(). The CLI's job is parsing input, calling the SDK, and formatting
705
+ * output.
706
+ *
707
+ * Output contract:
708
+ * - stdout: the CER bundle JSON (pipe-friendly into `nexart ai verify`)
709
+ * - stderr: a status banner (suppress with --quiet)
710
+ * - --out: also writes the bundle JSON to the given file
711
+ *
712
+ * The sealed bundle is integrity-only. It does NOT contain a node attestation
713
+ * receipt, a node signature, or an envelope. Use `nexart ai certify` (or
714
+ * `attest()` from the SDK) to upgrade a sealed bundle to a certified one.
715
+ *
716
+ * Compatibility guarantee: a sealed bundle from this command verifies as
717
+ * VERIFIED by `nexart ai verify`, by SDK `verifyAiCerBundleDetailed`, and by
718
+ * NexArt Node `/v1/cer/verify` (Integrity layer PASS, Receipt and Envelope
719
+ * layers SKIPPED — that is by design, not a failure).
720
+ *
721
+ * To merge upstream context signals into the bundle, include `signals: [...]`
722
+ * in the input JSON. Signals are bound by the SDK into certificateHash via
723
+ * context.contextHash + signalCount + contextSummary.
724
+ */
725
+ export async function aiSealCommand(file, opts, stdinReader = readStdinText) {
726
+ const raw = await readInputJson(file, stdinReader);
727
+ const { snapshotParams, sealOptions } = buildSealInputs(raw);
728
+ // Single source of truth: SDK builds the snapshot and seals it.
729
+ let bundle;
730
+ try {
731
+ const snapshot = sdkCreateSnapshot(snapshotParams);
732
+ bundle = sdkSealCer(snapshot, sealOptions);
733
+ }
734
+ catch (e) {
735
+ console.error(`[nexart] Error: SDK seal failed: ${e.message}`);
736
+ process.exit(1);
737
+ }
738
+ // Defensive invariant: a freshly sealed bundle must NOT carry an attestation.
739
+ // If the SDK ever returns one, surface it loudly rather than silently shipping
740
+ // a misclassified artifact.
741
+ if (sdkHasAttestation(bundle)) {
742
+ console.error('[nexart] Error: SDK returned a bundle with an attestation; refusing to label as sealed.');
743
+ process.exit(1);
744
+ }
745
+ const bundleJson = JSON.stringify(bundle, null, 2);
746
+ process.stdout.write(bundleJson + '\n');
747
+ if (opts.out) {
748
+ const outPath = writeFileAndVerify(opts.out, bundleJson);
749
+ console.error(`[nexart] Saved to: ${outPath}`);
750
+ }
751
+ if (!opts.quiet) {
752
+ const certHash = bundle.certificateHash ?? '(missing)';
753
+ console.error('');
754
+ console.error('[nexart] Local CER created');
755
+ console.error('[nexart] State: Sealed (integrity only)');
756
+ console.error('[nexart] No node attestation present');
757
+ console.error('[nexart] This record is not certified — use `nexart ai certify` to attest via a node');
758
+ console.error(`[nexart] certificateHash: ${certHash}`);
759
+ }
760
+ }
622
761
  /**
623
762
  * nexart ai create — Read execution input, call POST /v1/cer/ai/create,
624
763
  * print CER bundle JSON to stdout, optionally save to --out file.
@@ -730,94 +869,200 @@ export async function aiCertifyCommand(file, opts, fetchFn = globalThis.fetch, s
730
869
  }
731
870
  }
732
871
  /**
733
- * Check whether the input looks like a CER package an envelope object with
734
- * a `cer` field whose `bundleType` is 'cer.ai.execution.v1'.
872
+ * nexart ai verify Read a CER bundle or CER package JSON from file or stdin,
873
+ * delegate verification to verifyAiCerBundleDetailed() from @nexart/ai-execution,
874
+ * and report the protocol layer results.
875
+ *
876
+ * The CLI does ZERO crypto here. It only:
877
+ * 1. parses input
878
+ * 2. unwraps a CER package to its inner CER bundle (via SDK's isCerPackage / getCerFromPackage)
879
+ * 3. calls verifyAiCerBundleDetailed(bundle)
880
+ * 4. formats the SDK's CerVerificationResult for human / JSON output
735
881
  *
736
- * Mirrors the logic of isCerPackage() from @nexart/ai-execution without
737
- * adding a runtime dependency. The CLI is intentionally self-contained.
882
+ * The three SDK protocol layers are reported INDEPENDENTLY (no aggregation):
883
+ * - bundleIntegrity (Integrity layer: certificate hash recomputation)
884
+ * - nodeSignature (Receipt layer: node attestation signature validity)
885
+ * - receiptConsistency (Envelope layer: bundle/receipt envelope consistency)
886
+ *
887
+ * Each layer is PASS / FAIL / SKIPPED. SDK reason codes are passed through
888
+ * unmodified. Exit code is driven by the SDK's `status` field (VERIFIED → 0,
889
+ * anything else → 1).
890
+ *
891
+ * --json prints the SDK's CerVerificationResult verbatim, plus a minimal
892
+ * `cli` envelope describing input handling. The SDK fields are never rewritten
893
+ * or reinterpreted.
738
894
  */
739
- function isCerPackageInput(value) {
740
- const cer = value['cer'];
741
- if (typeof cer !== 'object' || cer === null)
742
- return false;
743
- return cer['bundleType'] === 'cer.ai.execution.v1';
895
+ export async function aiVerifyCommand(file, opts, stdinReader = readStdinText) {
896
+ const raw = await readInputJson(file, stdinReader);
897
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
898
+ console.error('[nexart] Error: Input is not a valid CER bundle or CER package object');
899
+ process.exit(1);
900
+ }
901
+ // Detect package vs raw bundle and unwrap. Both helpers are from the SDK,
902
+ // so the CLI stays in sync with however the SDK defines a "package".
903
+ const isPackage = sdkIsCerPackage(raw);
904
+ const inputType = isPackage ? 'package' : 'bundle';
905
+ let bundleForVerification;
906
+ if (isPackage) {
907
+ bundleForVerification = getCerFromPackage(raw);
908
+ }
909
+ else {
910
+ bundleForVerification = raw;
911
+ }
912
+ // Delegate ALL verification to the SDK. The CLI does not interpret, fall
913
+ // back, or aggregate. The result object is the protocol's source of truth.
914
+ const result = verifyAiCerBundleDetailed(bundleForVerification);
915
+ if (opts.json) {
916
+ // Pass the SDK result through verbatim. Add only a small `cli` envelope
917
+ // so callers can tell whether the input was a bundle or a package and
918
+ // which CLI version produced the output. SDK fields are NOT modified.
919
+ const output = {
920
+ ...result,
921
+ cli: {
922
+ version: CLI_VERSION,
923
+ sdkVersion: getSdkVersion(),
924
+ inputType,
925
+ ...(isPackage
926
+ ? {
927
+ verifiedInnerCer: true,
928
+ packageTrustLayersVerified: false,
929
+ }
930
+ : {}),
931
+ },
932
+ };
933
+ process.stdout.write(JSON.stringify(output, null, 2) + '\n');
934
+ }
935
+ else {
936
+ if (isPackage) {
937
+ console.log('Input type: CER package');
938
+ }
939
+ else {
940
+ console.log('Input type: CER bundle');
941
+ }
942
+ console.log(`Verification result: ${result.status}`);
943
+ console.log(`bundleType: ${result.bundleType || '(missing)'}`);
944
+ console.log(`certificateHash: ${result.certificateHash || '(missing)'}`);
945
+ console.log('');
946
+ console.log('Layers:');
947
+ console.log(` bundleIntegrity (Integrity): ${result.checks.bundleIntegrity}`);
948
+ console.log(` nodeSignature (Receipt): ${result.checks.nodeSignature}`);
949
+ console.log(` receiptConsistency (Envelope): ${result.checks.receiptConsistency}`);
950
+ if (result.reasonCodes.length > 0) {
951
+ console.log('');
952
+ console.log('Reason codes:');
953
+ for (const code of result.reasonCodes) {
954
+ console.log(` - ${code}`);
955
+ }
956
+ }
957
+ console.log('');
958
+ console.log(`Verifier: ${result.verifier} (via @nexart/cli ${CLI_VERSION})`);
959
+ console.log(`VerifiedAt: ${result.verifiedAt}`);
960
+ if (isPackage) {
961
+ console.log('');
962
+ console.log('Note: Verified inner CER bundle only. Package-level receipt/signature/envelope not verified by this command.');
963
+ }
964
+ }
965
+ process.exit(result.status === 'VERIFIED' ? 0 : 1);
744
966
  }
745
967
  /**
746
- * nexart ai verify — Read a CER bundle or CER package JSON from file or stdin,
747
- * verify integrity locally, report VERIFIED/FAILED.
748
- * --json outputs a canonical CerVerificationResult object.
968
+ * nexart ai project-verify — Read a cer.project.bundle.v1 JSON from file or stdin,
969
+ * delegate all verification to verifyProjectBundle() from @nexart/ai-execution,
970
+ * and report VERIFIED/FAILED.
971
+ *
972
+ * CLI responsibilities:
973
+ * - file/stdin input handling
974
+ * - artifact type routing (early discriminant check for clear user-facing errors)
975
+ * - calling verifyProjectBundle() from @nexart/ai-execution (canonical source of truth)
976
+ * - formatting ProjectBundleVerifyResult for human-readable terminal output
977
+ * - emitting the structured result in JSON mode
749
978
  *
750
- * v0.6.0: Also accepts CER packages ({ cer: <bundle>, receipt?, signature?, ... }).
751
- * When a package is detected, only the inner cer bundle is verified.
752
- * Package-level receipt/signature/envelope fields are NOT verified by this command.
979
+ * The CLI does NOT own project bundle schema, project hash computation,
980
+ * per-step verification logic, or any competing implementation of those semantics.
981
+ *
982
+ * Exit code: 0 on VERIFIED, 1 on FAILED or structural error.
983
+ * --json outputs a machine-readable result object.
753
984
  */
754
- export async function aiVerifyCommand(file, opts, stdinReader = readStdinText) {
985
+ export async function aiProjectVerifyCommand(file, opts, stdinReader = readStdinText) {
755
986
  const raw = await readInputJson(file, stdinReader);
756
987
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
757
- console.error('[nexart] Error: Input is not a valid CER bundle object');
988
+ console.error('[nexart] Error: Input is not a valid project bundle object');
758
989
  process.exit(1);
759
990
  }
760
991
  const input = raw;
761
- const verifiedAt = new Date().toISOString();
762
- // Detect whether input is a raw CER bundle or a CER package.
763
- const isPackage = isCerPackageInput(input);
764
- const inputType = isPackage ? 'package' : 'bundle';
765
- // Extract the bundle to verify: package → inner cer; raw bundle → itself.
766
- const bundle = isPackage
767
- ? input['cer']
768
- : input;
769
- const bundleType = typeof bundle['bundleType'] === 'string' ? bundle['bundleType'] : '';
770
- const certificateHash = typeof bundle['certificateHash'] === 'string' ? bundle['certificateHash'] : '';
771
- if (bundleType !== 'cer.ai.execution.v1') {
772
- console.error(`[nexart] Error: Unsupported bundleType: ${bundleType}`);
773
- console.error('[nexart] Expected: cer.ai.execution.v1');
992
+ // Early discriminant check — provides a clear user-facing error before delegating.
993
+ if (input['bundleType'] !== 'cer.project.bundle.v1') {
994
+ const got = typeof input['bundleType'] === 'string' ? input['bundleType'] : '(missing)';
995
+ console.error(`[nexart] Error: Unsupported bundleType: ${got}`);
996
+ console.error('[nexart] Expected: cer.project.bundle.v1');
774
997
  process.exit(1);
775
998
  }
776
- const computedHash = computeBundleHash(bundle);
777
- const integrityPass = computedHash === certificateHash;
778
- const attestationPresent = hasAttestation(bundle);
779
- const nodeSignature = attestationPresent ? 'PASS' : 'SKIPPED';
780
- const receiptConsistency = attestationPresent ? 'PASS' : 'SKIPPED';
781
- const reasonCodes = integrityPass ? [] : ['BUNDLE_HASH_MISMATCH'];
782
- const status = integrityPass ? 'VERIFIED' : 'FAILED';
783
- if (opts.json) {
999
+ // Delegate all verification logic to @nexart/ai-execution — the canonical source of truth.
1000
+ const result = verifyProjectBundle(raw);
1001
+ const verifiedAt = new Date().toISOString();
1002
+ emitProjectVerifyResult(result, verifiedAt, opts.json, input);
1003
+ process.exit(result.ok ? 0 : 1);
1004
+ }
1005
+ /**
1006
+ * Format a ProjectBundleVerifyResult returned by verifyProjectBundle() from
1007
+ * @nexart/ai-execution into human-readable or JSON terminal output.
1008
+ *
1009
+ * The raw input bundle is accepted only for output-only display fields that
1010
+ * are not carried in ProjectBundleVerifyResult: projectBundleId, projectTitle.
1011
+ * All verification logic and semantic fields come from the result object.
1012
+ */
1013
+ function emitProjectVerifyResult(result, verifiedAt, json, input) {
1014
+ const status = result.ok ? 'VERIFIED' : 'FAILED';
1015
+ const passedSteps = result.passedSteps ?? result.steps.filter(s => s.ok).length;
1016
+ const failedSteps = result.failedSteps ?? result.steps.filter(s => !s.ok).length;
1017
+ const structuralValid = result.structuralValid ?? result.ok;
1018
+ const projectHashIntegrity = result.projectHashValid === true ? 'PASS' :
1019
+ result.projectHashValid === false ? 'FAIL' :
1020
+ 'SKIPPED';
1021
+ const stepIntegrity = result.steps.length === 0 ? 'SKIPPED' :
1022
+ result.steps.every(s => s.ok) ? 'PASS' : 'FAIL';
1023
+ const totalSteps = result.totalSteps ?? (typeof input['totalSteps'] === 'number' ? input['totalSteps'] : null);
1024
+ if (json) {
784
1025
  const output = {
785
1026
  status,
1027
+ bundleType: 'cer.project.bundle.v1',
1028
+ projectBundleId: typeof input['projectBundleId'] === 'string' ? input['projectBundleId'] : null,
1029
+ projectTitle: typeof input['projectTitle'] === 'string' ? input['projectTitle'] : null,
1030
+ totalSteps,
1031
+ passedSteps,
1032
+ failedSteps,
1033
+ projectHashValid: result.projectHashValid ?? null,
786
1034
  checks: {
787
- bundleIntegrity: integrityPass ? 'PASS' : 'FAIL',
788
- nodeSignature,
789
- receiptConsistency,
1035
+ structuralValid,
1036
+ projectHashIntegrity,
1037
+ stepIntegrity,
790
1038
  },
791
- reasonCodes,
792
- certificateHash,
793
- bundleType,
794
- inputType,
1039
+ steps: result.steps,
1040
+ errors: result.errors,
795
1041
  verifiedAt,
796
1042
  verifier: '@nexart/cli',
797
1043
  };
798
- if (isPackage) {
799
- output['verifiedInnerCer'] = true;
800
- output['packageTrustLayersVerified'] = false;
801
- }
802
1044
  process.stdout.write(JSON.stringify(output, null, 2) + '\n');
803
1045
  }
804
1046
  else {
805
- if (isPackage) {
806
- console.log('Input type: CER package');
807
- }
808
1047
  console.log(`Verification result: ${status}`);
809
- console.log(`bundleIntegrity: ${integrityPass ? 'PASS' : 'FAIL'}`);
810
- console.log(`nodeSignature: ${nodeSignature}`);
811
- console.log(`receiptConsistency: ${receiptConsistency}`);
812
- if (isPackage) {
813
- console.log('Note: Verified inner CER bundle only. Package-level receipt/signature/envelope not verified by this command.');
1048
+ console.log(`bundleType: cer.project.bundle.v1`);
1049
+ if (typeof input['projectBundleId'] === 'string') {
1050
+ console.log(`projectBundleId: ${input['projectBundleId']}`);
1051
+ }
1052
+ if (typeof input['projectTitle'] === 'string') {
1053
+ console.log(`projectTitle: ${input['projectTitle']}`);
1054
+ }
1055
+ if (totalSteps !== null) {
1056
+ console.log(`totalSteps: ${totalSteps}`);
814
1057
  }
815
- if (!integrityPass) {
816
- console.error(`[nexart] Expected hash: ${certificateHash}`);
817
- console.error(`[nexart] Computed hash: ${computedHash}`);
1058
+ console.log(`passedSteps: ${passedSteps}`);
1059
+ console.log(`failedSteps: ${failedSteps}`);
1060
+ console.log(`projectHashIntegrity: ${projectHashIntegrity}`);
1061
+ console.log(`stepIntegrity: ${stepIntegrity}`);
1062
+ for (const err of result.errors) {
1063
+ console.error(`[nexart] Error: ${err}`);
818
1064
  }
819
1065
  }
820
- process.exit(integrityPass ? 0 : 1);
821
1066
  }
822
1067
  // ─── CLI Setup ─────────────────────────────────────────────────────────────
823
1068
  const _isMainModule = (() => {
@@ -1054,7 +1299,38 @@ if (_isMainModule) {
1054
1299
  }, async (argv) => {
1055
1300
  await aiCertifyCommand(argv.file, { out: argv.out, json: argv.json, endpoint: argv.endpoint, apiKey: argv['api-key'], signalsFile: argv['signals-file'] });
1056
1301
  })
1057
- .command('verify [file]', 'Verify a CER bundle or CER package locally (no network required)', (yargs) => {
1302
+ .command('seal [file]', 'Create a Certified Execution Record locally (no node required) — produces an integrity-only bundle for offline workflows, CI, or pre-certification', (yargs) => {
1303
+ return yargs
1304
+ .positional('file', {
1305
+ describe: 'Path to execution input JSON (omit to read from stdin)',
1306
+ type: 'string',
1307
+ })
1308
+ .option('out', {
1309
+ alias: 'o',
1310
+ describe: 'Save sealed CER bundle to this file',
1311
+ type: 'string',
1312
+ })
1313
+ .option('quiet', {
1314
+ alias: 'q',
1315
+ describe: 'Suppress the human-readable status banner on stderr (clean pipe output)',
1316
+ type: 'boolean',
1317
+ default: false,
1318
+ })
1319
+ .example('$0 ai seal execution.json', 'Seal locally and print bundle JSON')
1320
+ .example('cat execution.json | $0 ai seal --quiet | $0 ai verify', 'Seal then verify in a pipe')
1321
+ .example('$0 ai seal execution.json --out cer.json', 'Save sealed bundle to file')
1322
+ .example('$0 ai seal execution.json --quiet > cer.json', 'Redirect bundle JSON only')
1323
+ .epilogue([
1324
+ 'Sealed (this command): integrity only. No node receipt, no signature, no envelope.',
1325
+ 'Certified (ai certify): integrity + node attestation receipt + signature.',
1326
+ '',
1327
+ 'A sealed bundle verifies as VERIFIED with Receipt and Envelope reported as SKIPPED.',
1328
+ 'That is correct, not a failure: only the Integrity layer applies to local artifacts.',
1329
+ ].join('\n'));
1330
+ }, async (argv) => {
1331
+ await aiSealCommand(argv.file, { out: argv.out, quiet: argv.quiet });
1332
+ })
1333
+ .command('verify [file]', 'Verify a CER bundle or CER package locally via @nexart/ai-execution (no network required)', (yargs) => {
1058
1334
  return yargs
1059
1335
  .positional('file', {
1060
1336
  describe: 'Path to CER bundle or CER package JSON (omit to read from stdin)',
@@ -1073,23 +1349,55 @@ if (_isMainModule) {
1073
1349
  }, async (argv) => {
1074
1350
  await aiVerifyCommand(argv.file, { json: argv.json });
1075
1351
  })
1076
- .demandCommand(1, 'You must provide a subcommand: create, certify, or verify')
1352
+ .command('project-verify [file]', 'Verify a project bundle (cer.project.bundle.v1) locally (no network required)', (yargs) => {
1353
+ return yargs
1354
+ .positional('file', {
1355
+ describe: 'Path to project bundle JSON (omit to read from stdin)',
1356
+ type: 'string',
1357
+ })
1358
+ .option('json', {
1359
+ alias: 'j',
1360
+ describe: 'Print machine-readable JSON result',
1361
+ type: 'boolean',
1362
+ default: false,
1363
+ })
1364
+ .example('$0 ai project-verify bundle.json', 'Verify a project bundle file')
1365
+ .example('cat bundle.json | $0 ai project-verify', 'Verify from stdin')
1366
+ .example('$0 ai project-verify bundle.json --json', 'Machine-readable output');
1367
+ }, async (argv) => {
1368
+ await aiProjectVerifyCommand(argv.file, { json: argv.json });
1369
+ })
1370
+ .demandCommand(1, 'You must provide a subcommand: create, certify, verify, or project-verify')
1077
1371
  .help();
1078
1372
  }, () => { })
1079
1373
  .demandCommand(1, 'You must provide a command')
1080
1374
  .help()
1081
- .version(CLI_VERSION)
1375
+ .version(`@nexart/cli ${CLI_VERSION}
1376
+ @nexart/ai-execution ${getSdkVersion()}
1377
+ codemode protocol ${PROTOCOL_VERSION}`)
1082
1378
  .epilog(`
1083
1379
  Environment Variables:
1084
1380
  NEXART_RENDERER_ENDPOINT Remote renderer URL (default: http://localhost:5000)
1085
1381
  NEXART_NODE_ENDPOINT NexArt node API URL (default: https://node.nexart.art)
1086
1382
  NEXART_API_KEY API key for authenticated requests
1087
1383
 
1088
- Output Files:
1384
+ Output Files (Code Mode):
1089
1385
  --out ./out.png creates:
1090
1386
  ./out.png - The rendered PNG image
1091
1387
  ./out.snapshot.json - Snapshot for replay/verify
1092
1388
 
1389
+ AI verification (ai verify, ai project-verify):
1390
+ All cryptographic verification is delegated to @nexart/ai-execution
1391
+ (verifyAiCerBundleDetailed, verifyProjectBundle). The CLI itself implements
1392
+ no hashing or canonicalization and produces results that match the SDK,
1393
+ NexArt Node (/v1/cer/verify), and verify.nexart.io exactly.
1394
+
1395
+ ai verify reports the three SDK protocol layers independently:
1396
+ - bundleIntegrity (Integrity: certificate hash recomputation)
1397
+ - nodeSignature (Receipt: node attestation signature validity)
1398
+ - receiptConsistency (Envelope: bundle/receipt envelope consistency)
1399
+ No aggregation, no reinterpretation, SDK reason codes preserved verbatim.
1400
+
1093
1401
  Examples:
1094
1402
  # Run with remote renderer
1095
1403
  npx @nexart/cli run ./sketch.js --seed 123 --include-code --out ./out.png
@@ -1109,11 +1417,17 @@ Examples:
1109
1417
  # AI: Certify with context signals
1110
1418
  npx @nexart/cli ai certify execution.json --signals-file signals.json
1111
1419
 
1112
- # AI: Verify a raw CER bundle locally (signals-containing bundles supported)
1420
+ # AI: Verify a raw CER bundle locally (delegates to SDK)
1113
1421
  npx @nexart/cli ai verify cer.json
1114
1422
 
1115
- # AI: Verify a CER package (inner bundle verified; receipt/envelope not verified)
1423
+ # AI: Verify a CER package (inner bundle verified via SDK; package envelope not)
1116
1424
  npx @nexart/cli ai verify cer-package.json
1425
+
1426
+ # AI: Verify a project bundle locally (no network required)
1427
+ npx @nexart/cli ai project-verify project-bundle.json
1428
+
1429
+ # AI: Verify a project bundle with machine-readable JSON output
1430
+ npx @nexart/cli ai project-verify project-bundle.json --json
1117
1431
  `)
1118
1432
  .parse();
1119
1433
  }