@sequenceholdings/studio-cli 0.1.18 → 0.1.22

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.
@@ -16,7 +16,8 @@ import { existsSync, readdirSync, rmSync } from 'node:fs';
16
16
  import { homedir } from 'node:os';
17
17
  import { resolve, sep } from 'node:path';
18
18
  import { materializeRepo, resolveCommitSha, resolveRepo, } from '@sequenceholdings/artifact-studio/git-service-client';
19
- import { deleteNoContent, getJson, postJson } from '../atlas-client.js';
19
+ import { z } from 'zod';
20
+ import { AtlasApiError, deleteNoContent, getJson, getJsonOr404, postJson, } from '../atlas-client.js';
20
21
  import { printCliError } from '../cli-errors.js';
21
22
  import { PREVIEW_DOMAIN } from '../preview.js';
22
23
  import { confirmYes } from '../prompt.js';
@@ -468,6 +469,257 @@ function defaultOutFromCloneTarget({ url, id, }) {
468
469
  const match = /\/repos\/([^/]+)\/git\/?$/.exec(new URL(url).pathname);
469
470
  return match?.[1] ?? 'repo';
470
471
  }
472
+ /**
473
+ * Mirror of atlas `ci-discover` Zod contract so `repos ci show` fails closed
474
+ * on the same invalid `.seq/ci.json` shapes the runner rejects (`ci/config`).
475
+ * Keep in sync with `atlas/src/server/services/git-service/ci-discover.ts`.
476
+ */
477
+ const CiPhaseSchema = z.enum(['check', 'test']);
478
+ const CiJsonEntrySchema = z
479
+ .object({
480
+ phase: CiPhaseSchema,
481
+ name: z
482
+ .string()
483
+ .min(1)
484
+ .max(64)
485
+ .regex(/^[a-z][a-z0-9._/-]*$/)
486
+ .optional(),
487
+ script: z.string().min(1).max(128).optional(),
488
+ command: z.array(z.string().min(1).max(256)).min(1).max(32).optional(),
489
+ })
490
+ .superRefine((entry, ctx) => {
491
+ const hasScript = entry.script != null;
492
+ const hasCommand = entry.command != null;
493
+ if (hasScript === hasCommand) {
494
+ ctx.addIssue({
495
+ code: z.ZodIssueCode.custom,
496
+ message: 'each check must specify exactly one of script or command',
497
+ path: hasScript ? ['command'] : ['script'],
498
+ });
499
+ }
500
+ });
501
+ const CiJsonSchema = z.object({
502
+ checks: z.array(CiJsonEntrySchema).max(16),
503
+ });
504
+ /** Match atlas `ci-discover` defaultNameForPhase (per-phase index, not array index). */
505
+ function defaultNameForPhase(phase, indexInPhase) {
506
+ if (indexInPhase === 0)
507
+ return `ci/${phase}`;
508
+ return `ci/${phase}-${indexInPhase + 1}`;
509
+ }
510
+ /**
511
+ * Pure discovery shared by `repos ci show` / `import`. Mirrors atlas
512
+ * `discoverCiChecks` / `parseCiJson` so CLI preview matches execution.
513
+ */
514
+ export function discoverCliChecks({ ciJson, packageScripts, }) {
515
+ if (ciJson !== undefined) {
516
+ const parsed = CiJsonSchema.safeParse(ciJson);
517
+ if (!parsed.success) {
518
+ const detail = parsed.error.issues
519
+ .map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)
520
+ .slice(0, 3)
521
+ .join('; ');
522
+ return { ok: false, error: `Invalid .seq/ci.json: ${detail}` };
523
+ }
524
+ const phaseCounts = new Map();
525
+ const seenNames = new Set();
526
+ const checks = [];
527
+ for (const entry of parsed.data.checks) {
528
+ const indexInPhase = phaseCounts.get(entry.phase) ?? 0;
529
+ phaseCounts.set(entry.phase, indexInPhase + 1);
530
+ const name = entry.name ?? defaultNameForPhase(entry.phase, indexInPhase);
531
+ if (seenNames.has(name)) {
532
+ return {
533
+ ok: false,
534
+ error: `Invalid .seq/ci.json: duplicate check name "${name}"`,
535
+ };
536
+ }
537
+ seenNames.add(name);
538
+ const check = {
539
+ name,
540
+ source: 'ci.json',
541
+ };
542
+ if (entry.script != null)
543
+ check.script = entry.script;
544
+ if (entry.command != null)
545
+ check.command = entry.command;
546
+ checks.push(check);
547
+ }
548
+ return { ok: true, checks };
549
+ }
550
+ const checks = [];
551
+ if (packageScripts) {
552
+ for (const candidate of ['lint', 'typecheck', 'check']) {
553
+ if (packageScripts[candidate]?.trim()) {
554
+ checks.push({ name: 'ci/check', script: candidate, source: 'autodiscover' });
555
+ break;
556
+ }
557
+ }
558
+ if (packageScripts.test?.trim()) {
559
+ checks.push({ name: 'ci/test', script: 'test', source: 'autodiscover' });
560
+ }
561
+ }
562
+ return { ok: true, checks };
563
+ }
564
+ function decodeFileContent(file) {
565
+ return file.encoding === 'base64'
566
+ ? Buffer.from(file.content, 'base64').toString('utf8')
567
+ : file.content;
568
+ }
569
+ /**
570
+ * Load `.seq/ci.json` / `package.json` for CI preview.
571
+ *
572
+ * Matches the runner: only a true 404 means "file absent". Invalid JSON and
573
+ * non-404 API errors fail closed (no silent package.json fallback over a bad
574
+ * `.seq/ci.json`).
575
+ */
576
+ export async function loadRemoteCiSources({ ctx, repoId, ref, }) {
577
+ let ciJson;
578
+ try {
579
+ const file = await getJsonOr404({
580
+ ...clientOptions(ctx),
581
+ path: `/api/git-service/repos/${repoId}/contents/.seq/ci.json?ref=${encodeURIComponent(ref)}`,
582
+ });
583
+ if (file != null) {
584
+ try {
585
+ ciJson = JSON.parse(decodeFileContent(file));
586
+ }
587
+ catch {
588
+ return { ok: false, error: 'invalid .seq/ci.json: not valid JSON' };
589
+ }
590
+ }
591
+ }
592
+ catch (err) {
593
+ if (err instanceof AtlasApiError) {
594
+ return { ok: false, error: `failed to fetch .seq/ci.json: ${err.message}` };
595
+ }
596
+ throw err;
597
+ }
598
+ // When ci.json is present (including empty checks), skip package.json —
599
+ // discovery never falls through to autodiscover in that case.
600
+ if (ciJson !== undefined) {
601
+ return { ok: true, ciJson };
602
+ }
603
+ let packageScripts;
604
+ try {
605
+ const file = await getJsonOr404({
606
+ ...clientOptions(ctx),
607
+ path: `/api/git-service/repos/${repoId}/contents/package.json?ref=${encodeURIComponent(ref)}`,
608
+ });
609
+ if (file != null) {
610
+ let pkg;
611
+ try {
612
+ pkg = JSON.parse(decodeFileContent(file));
613
+ }
614
+ catch {
615
+ return { ok: false, error: 'invalid package.json: not valid JSON' };
616
+ }
617
+ if (pkg.scripts && typeof pkg.scripts === 'object') {
618
+ packageScripts = {};
619
+ for (const [key, value] of Object.entries(pkg.scripts)) {
620
+ if (typeof value === 'string')
621
+ packageScripts[key] = value;
622
+ }
623
+ }
624
+ }
625
+ }
626
+ catch (err) {
627
+ if (err instanceof AtlasApiError) {
628
+ return { ok: false, error: `failed to fetch package.json: ${err.message}` };
629
+ }
630
+ throw err;
631
+ }
632
+ return { ok: true, packageScripts };
633
+ }
634
+ /**
635
+ * Discover CI checks from a remote repo tip (pure preview — no Settings write).
636
+ * Uses the same autodiscovery rules as the platform runner (PLA-378).
637
+ */
638
+ export async function reposCiShowCommand(args) {
639
+ const { namespace, name } = parseRepoPath(args.positional[0]);
640
+ const ctx = await reposContext(args);
641
+ const resolved = await resolveRepo({ ...clientOptions(ctx), namespace, name });
642
+ const repo = await getJson({
643
+ ...clientOptions(ctx),
644
+ path: `/api/git-service/repos/${resolved.id}`,
645
+ });
646
+ const ref = stringFlag(args.flags, 'ref') ?? repo.defaultBranch;
647
+ const required = new Set(repo.reviewPolicy?.requiredChecks ?? []);
648
+ const sources = await loadRemoteCiSources({ ctx, repoId: resolved.id, ref });
649
+ if (!sources.ok) {
650
+ console.error(`${LOG} ${sources.error} on ${namespace}/${name}@${ref}`);
651
+ return 1;
652
+ }
653
+ const discovered = discoverCliChecks(sources);
654
+ if (!discovered.ok) {
655
+ console.error(`${LOG} ${discovered.error} on ${namespace}/${name}@${ref}`);
656
+ return 1;
657
+ }
658
+ console.log(`${LOG} CI for ${namespace}/${name}@${ref} on ${ctx.env.name}:`);
659
+ if (discovered.checks.length === 0) {
660
+ console.log(' (no checks discovered)');
661
+ return 0;
662
+ }
663
+ for (const check of discovered.checks) {
664
+ const how = check.script != null
665
+ ? `script=${check.script}`
666
+ : check.command != null
667
+ ? `command=${JSON.stringify(check.command)}`
668
+ : '';
669
+ console.log(` ${check.name} ${how} source=${check.source} required=${required.has(check.name)}`);
670
+ }
671
+ return 0;
672
+ }
673
+ const REQUIRED_CHECKS_WRITE_DISABLED = `${LOG} writing requiredChecks is disabled until the sandboxed CI runner is live ` +
674
+ `(discovery posts neutral check-runs that cannot satisfy the merge gate). ` +
675
+ `Use \`seq-studio repos ci show\` to preview discovered names.`;
676
+ /** Add a check-run name to Settings `requiredChecks` (merge-blocking). */
677
+ export async function reposCiRequireCommand(args) {
678
+ const check = stringFlag(args.flags, 'check');
679
+ if (!check) {
680
+ console.error('usage: seq-studio repos ci require <ns>/<name> --check <name> -e <env>');
681
+ return 1;
682
+ }
683
+ console.error(REQUIRED_CHECKS_WRITE_DISABLED);
684
+ return 1;
685
+ }
686
+ /**
687
+ * Set requiredChecks to every check discovered on the tip (opt-in bulk require).
688
+ */
689
+ export async function reposCiImportCommand(args) {
690
+ // Still run show so authors can preview names, then refuse the write.
691
+ const code = await reposCiShowCommand(args);
692
+ if (code !== 0)
693
+ return code;
694
+ console.error(REQUIRED_CHECKS_WRITE_DISABLED);
695
+ return 1;
696
+ }
697
+ export async function reposCiCommand(args) {
698
+ const [action] = args.positional;
699
+ const rest = {
700
+ ...args,
701
+ positional: args.positional.slice(1),
702
+ };
703
+ switch (action) {
704
+ case 'show':
705
+ return reposCiShowCommand(rest);
706
+ case 'require':
707
+ return reposCiRequireCommand(rest);
708
+ case 'import':
709
+ return reposCiImportCommand(rest);
710
+ case 'help':
711
+ case undefined:
712
+ console.log(`usage:
713
+ seq-studio repos ci show <ns>/<name> -e <env> [--ref r] preview discovered CI checks
714
+ seq-studio repos ci require <ns>/<name> --check <name> -e <env> reserved (refuses write until sandboxed runner)
715
+ seq-studio repos ci import <ns>/<name> -e <env> [--ref r] reserved (refuses write until sandboxed runner)
716
+ `);
717
+ return action ? 0 : 1;
718
+ default:
719
+ console.error(`unknown repos ci command: ${action}`);
720
+ return 1;
721
+ }
722
+ }
471
723
  export async function reposDeleteCommand(args) {
472
724
  const { namespace, name } = parseRepoPath(args.positional[0]);
473
725
  const ctx = await reposContext(args);
@@ -505,6 +757,9 @@ export const REPOS_USAGE = `usage:
505
757
  seq-studio repos pull <ns>/<name> -e <env> [--ref r] [--out dir] [--force]
506
758
  materialize the tree at a ref (JSON API)
507
759
  seq-studio repos delete <ns>/<name> -e <env> [--yes] delete a repo (confirm prompt)
760
+ seq-studio repos ci show <ns>/<name> -e <env> [--ref r] preview discovered CI checks
761
+ seq-studio repos ci require <ns>/<name> --check <name> -e <env> reserved (refuses write until sandboxed runner)
762
+ seq-studio repos ci import <ns>/<name> -e <env> [--ref r] reserved (refuses write until sandboxed runner)
508
763
 
509
764
  Flags: -e/--env <env> (required; see: seq-studio envs list)
510
765
 
@@ -534,6 +789,8 @@ export async function runReposCommand(sub, args) {
534
789
  return await reposPullCommand(args);
535
790
  case 'delete':
536
791
  return await reposDeleteCommand(args);
792
+ case 'ci':
793
+ return await reposCiCommand(args);
537
794
  case 'help':
538
795
  case '--help':
539
796
  case '-h':
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sequenceholdings/studio-cli",
3
- "version": "0.1.18",
4
- "description": "Unified Sequence Studio CLI — `seq-studio process` (Lattice), `seq-studio artifact` (Artifact Studio), `seq-studio functions` / `secrets`, `seq-studio repos` (platform git-service), and `seq-studio auth pat` (git-service PATs). Includes Auth0 browser login shared with seqapi.",
3
+ "version": "0.1.22",
4
+ "description": "Unified Sequence Studio CLI — `seq-studio agents` (typed agent definitions), `seq-studio process` (Lattice), `seq-studio artifact` (Artifact Studio), `seq-studio functions` / `secrets`, `seq-studio repos` (platform git-service), and `seq-studio auth pat` (git-service PATs). Includes Auth0 browser login shared with seqapi.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
7
7
  "type": "git",
@@ -40,12 +40,13 @@
40
40
  "smol-toml": "^1.4.2",
41
41
  "tsx": "^4.20.3",
42
42
  "zod": "^4.1.13",
43
- "@sequenceholdings/artifact-studio": "0.1.15",
43
+ "@sequenceholdings/agent-spec": "0.1.0",
44
+ "@sequenceholdings/artifact-studio": "0.2.0",
44
45
  "@sequenceholdings/lattice": "0.1.2"
45
46
  },
46
47
  "peerDependencies": {
47
- "@sequenceholdings/pipeline-spec": "0.1.0",
48
- "@sequenceholdings/orm": "0.1.1"
48
+ "@sequenceholdings/orm": "0.1.2",
49
+ "@sequenceholdings/pipeline-spec": "0.1.0"
49
50
  },
50
51
  "peerDependenciesMeta": {
51
52
  "@sequenceholdings/orm": {
@@ -60,7 +61,7 @@
60
61
  "@types/node": "^22.0.0",
61
62
  "typescript": "^5.6.0",
62
63
  "vitest": "^4.1.5",
63
- "@sequenceholdings/orm": "0.1.1",
64
+ "@sequenceholdings/orm": "0.1.2",
64
65
  "@sequenceholdings/pipeline-spec": "0.1.0"
65
66
  },
66
67
  "engines": {
@@ -70,6 +71,7 @@
70
71
  "studio",
71
72
  "sequence",
72
73
  "lattice",
74
+ "agents",
73
75
  "process",
74
76
  "artifact",
75
77
  "cli"