@sequenceholdings/studio-cli 0.1.21 → 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.
package/README.md CHANGED
@@ -145,13 +145,17 @@ bundle; it never implicitly deletes agents.
145
145
  | Command | What it does |
146
146
  |---------|--------------|
147
147
  | `seq-studio agents init <dir>` | Scaffold a standalone typed agent repository |
148
- | `seq-studio agents validate [--dir <dir>] [--target <APP_ENV>]` | Compile and validate locally, without API access |
149
- | `seq-studio agents plan [--dir <dir>]` | Offline compile/hash plan |
150
- | `seq-studio agents plan [--dir <dir>] -e <env> [--target <APP_ENV>]` | Diff creates, updates, and unchanged definitions against an environment |
151
- | `seq-studio agents apply [--dir <dir>] -e <env> [--target <APP_ENV>] [--yes]` | Apply creates and updates after showing the plan |
148
+ | `seq-studio agents validate [--dir <dir>] [--target <APP_ENV>] [--only <id1,id2>]` | Compile and validate locally, without API access |
149
+ | `seq-studio agents plan [--dir <dir>] [--only <id1,id2>]` | Offline compile/hash plan |
150
+ | `seq-studio agents plan [--dir <dir>] -e <env> [--target <APP_ENV>] [--only <id1,id2>]` | Diff creates, updates, and unchanged definitions against an environment |
151
+ | `seq-studio agents apply [--dir <dir>] -e <env> [--target <APP_ENV>] [--only <id1,id2>] [--yes]` | Apply creates and updates after showing the plan |
152
152
  | `seq-studio agents list -e <env>` | List visible runtime agents |
153
153
  | `seq-studio agents show <id> -e <env>` | Show one runtime agent |
154
154
 
155
+ For `validate`, `plan`, and `apply`, `--only` accepts a comma-separated list of
156
+ agent IDs after deployment-environment selection; every requested ID must be
157
+ selected.
158
+
155
159
  An optional `deploy-manifest.json` targets definitions by deployment identity:
156
160
 
157
161
  ```json
@@ -187,8 +191,10 @@ Use `--repo agents/<name> [--ref <ref>]` or `--git-url <url>` instead of
187
191
  ## ORM commands
188
192
 
189
193
  `seq-studio orm` authors and deploys governed ORM v2 namespaces: TypeScript
190
- table definitions and policies plus named GraphQL documents compiled into
191
- persisted operations.
194
+ table definitions, Drizzle-authored read-only views, and policies plus named
195
+ GraphQL documents compiled into persisted operations. Import Drizzle query
196
+ helpers from `@sequenceholdings/orm/drizzle`; managed view builders are
197
+ compiled to canonical SQL before registration.
192
198
 
193
199
  | Command | What it does |
194
200
  |---------|--------------|
@@ -335,6 +341,40 @@ same repo share Git review and commit provenance, but keep separate manifests,
335
341
  versions, runtime resources, secrets, and permissions. `--path` accepts only a
336
342
  canonical relative directory inside a remote repo; use `--dir` for local source.
337
343
 
344
+ ### Scaling
345
+
346
+ Managed functions scale to zero by default. Set a bounded warm pool in
347
+ `managed-function.yml` when first-request latency matters:
348
+
349
+ ```yaml
350
+ limits:
351
+ min_instances: 1
352
+ max_instances: 3
353
+ ```
354
+
355
+ `min_instances` defaults to `0`, cannot exceed `max_instances`, and incurs
356
+ Cloud Run idle-instance charges while warm.
357
+
358
+ ### ORM data access
359
+
360
+ A function declares its ORM Data API reach in `capabilities.data`, grouped by
361
+ namespace: `tables` it may read, v1 `actions` and ORM v2 persisted `operations`
362
+ it may invoke, and whether raw read `query` is allowed. At invoke time the
363
+ platform mints a short-lived data token scoped to exactly these refs — an
364
+ operation is scoped as `<namespace>/ops/<OperationName>` (the GraphQL operation
365
+ name from the namespace's `graphql/` documents, case-sensitive), and anything
366
+ undeclared is denied by the Data API. A function that reaches any namespace
367
+ must also attach a top-level `service_account`:
368
+
369
+ ```yaml
370
+ service_account: lucky-svc
371
+ capabilities:
372
+ data:
373
+ lucky:
374
+ tables: [lucky_draws]
375
+ operations: [RollLuckyNumber]
376
+ ```
377
+
338
378
  ## Artifact commands
339
379
 
340
380
  `seq-studio artifact <sub>` is the entry point for Artifact Studio. It runs
@@ -467,11 +507,34 @@ JSON API — the same repos `--repo <ns>/<name>` sources build from.
467
507
  | `seq-studio repos clone <ns>/<name> \| --url <clone-url> \| --id <uuid> -e <env> [--ref <r>] [--out <dir>] [--force]` | smart-HTTP `git clone` when `ATLAS_GIT_PAT` is set (`--url`/`--id` need no seqapi); otherwise JSON materialize + PAT hint |
468
508
  | `seq-studio repos pull <ns>/<name> -e <env> [--ref <r>] [--out <dir>] [--force]` | always materialize via JSON API (no `.git` dir); refuses a non-empty destination unless `--force` |
469
509
  | `seq-studio repos delete <ns>/<name> -e <env> [--yes]` | delete a repo — interactive confirm unless `--yes` |
510
+ | `seq-studio repos ci show <ns>/<name> -e <env> [--ref <r>]` | preview CI checks discovered from the ref |
511
+ | `seq-studio repos ci require <ns>/<name> --check <name> -e <env>` | reserved for requiring a named CI check; currently refuses to write until the sandboxed runner is live |
512
+ | `seq-studio repos ci import <ns>/<name> -e <env> [--ref <r>]` | reserved for requiring every discovered check; currently refuses to write until the sandboxed runner is live |
470
513
 
471
514
  `show` prints the smart-HTTP clone URL (`…/repos/<id>/git`). Basic auth:
472
515
  any username, PAT as password. Prefer `repos clone` over hand-rolling the
473
516
  tree API.
474
517
 
518
+ PR CI discovers `ci/check` from the first available `lint`, `typecheck`, or
519
+ `check` script and `ci/test` from `test`. A `.seq/ci.json` takes precedence;
520
+ its `checks` array can declare script/argv checks or be empty to opt out.
521
+
522
+ For example:
523
+
524
+ { "checks": [
525
+ { "phase": "check", "script": "lint" },
526
+ { "phase": "test", "command": ["pnpm", "test"] }
527
+ ] }
528
+
529
+ Each check needs `phase` (`check` or `test`) and exactly one of `script` or
530
+ `command`; `name` is optional and otherwise defaults to `ci/<phase>`
531
+ (`-2`, etc. for additional checks in that phase).
532
+ Discovery alone never blocks a merge. Until the sandboxed executor is live,
533
+ `repos ci show` is preview-only, Settings controls are disabled, and
534
+ `require`/`import` refuse to write (discovery currently posts `neutral`
535
+ check-runs). Once the executor is live, repo owners can opt in by requiring
536
+ check names in Settings or with `repos ci require`/`repos ci import`.
537
+
475
538
  ## Pipeline commands
476
539
 
477
540
  `seq-studio pipeline <sub>` authors and validates Data Pipelines **stage
@@ -486,6 +549,8 @@ install it alongside the CLI to use this family.
486
549
  | `seq-studio pipeline validate [dir] [--assets <file\|url>] [--json]` | Run the full offline spec gate: envelope + body validation, `schema_ref` resolution, and repo-level graph validation (reference resolution, single-writer, cycles, column subsets, serving projection checks). Exit 0/1 |
487
550
  | `seq-studio pipeline plan --repo pipelines/<slug> --ref <sha\|branch> -e <env> [--json]` | Plan a Pipeline deploy (materialize → SDK/`validateSpecGraph` → compile → live-diff → provision findings). Does **not** run Databricks `bundle validate` (that is a Trigger deploy-path hard gate). Exit 1 on destructive findings (CI-safe). `--json` emits the stable plan envelope |
488
551
  | `seq-studio pipeline deploy --repo pipelines/<slug> --ref <sha> -e <env> [--approved-by <sub>] [--no-wait]` | Plan then enqueue deploy; Trigger runs `bundle validate` then `bundle deploy` against reviewed bytes. Polls to terminal unless `--no-wait`. Production/banksouth require a pinned 40-hex SHA (client + server) |
552
+ | `seq-studio pipeline adopt --stage <slug> --ref <sha\|branch> -e <env> --native-id <id> --approved-by <you> [--resource-key <key>] [--kind job\|dlt_pipeline] [--old-source-removal-pr <url>] [--repo pipelines/<slug>]` | Bind a live Databricks job/pipeline into the stage without recreation (`bundle deployment bind` on Trigger). Always requires `--approved-by` naming the caller. When the key is still in the monorepo DAB, pass `--old-source-removal-pr` and follow the returned cutover checklist: unbind the old bundle state without deleting the remote, then remove its DAB declaration and add the target-specific adopted-resource entry in the same PR before redeploying. |
553
+ | `seq-studio pipeline unbind --stage <slug> --ref <sha\|branch> -e <env> --approved-by <you> [--resource-key <key>] [--repo pipelines/<slug>]` | Release an adopted binding on Trigger; the remote object stays live (never deleted) |
489
554
  | `seq-studio pipeline run-now --stage <slug> -e <env> [--repo pipelines/<slug>] [--json]` | Run the stage's active job or DLT pipeline immediately and print its Databricks run URL |
490
555
  | `seq-studio pipeline promote --stage <slug> --version <v> -e <env> [--repo pipelines/<slug>] [--approved-by <you>] [--no-wait]` | Promote a validated version to another environment. Prod/banksouth require `--approved-by` naming the authenticated caller (approvals are self-recorded); `--repo` disambiguates a slug that exists in multiple Pipelines |
491
556
  | `seq-studio pipeline rollback --stage <slug> -e <env> [--repo pipelines/<slug>] [--approved-by <you>] [--no-wait]` | Redeploy the previously retired deployment's version. Prod/banksouth require `--approved-by` — approvals are explicit even for rollbacks |
@@ -6,5 +6,5 @@ export declare function agentsPlanCommand(args: ParsedArgs): Promise<number>;
6
6
  export declare function agentsApplyCommand(args: ParsedArgs): Promise<number>;
7
7
  export declare function agentsListCommand(args: ParsedArgs): Promise<number>;
8
8
  export declare function agentsShowCommand(args: ParsedArgs): Promise<number>;
9
- export declare const AGENTS_USAGE = "usage:\n seq-studio agents init <dir> scaffold a typed agent\n seq-studio agents validate [--dir d] [--target app] offline compile + validation\n seq-studio agents plan [--dir d] [-e <env>] offline bundle plan or live diff\n seq-studio agents apply [--dir d] -e <env> [--yes] apply creates/updates; never deletes\n seq-studio agents list -e <env> list visible agents\n seq-studio agents show <id> -e <env> show one agent\n\n Source: local --dir (default .), --repo agents/<name>, or --git-url <url>.\n Use --ref for remote sources. --target selects the deployment APP_ENV when it\n differs from the CLI environment alias (notably OpCo registrations).\n";
9
+ export declare const AGENTS_USAGE = "usage:\n seq-studio agents init <dir> scaffold a typed agent\n seq-studio agents validate [--dir d] [--target app] [--only ids] offline compile + validation\n seq-studio agents plan [--dir d] [-e <env>] [--only ids] offline bundle plan or live diff\n seq-studio agents apply [--dir d] -e <env> [--only ids] [--yes] apply creates/updates; never deletes\n seq-studio agents list -e <env> list visible agents\n seq-studio agents show <id> -e <env> show one agent\n\n Source: local --dir (default .), --repo agents/<name>, or --git-url <url>.\n Use --ref for remote sources. --target selects the deployment APP_ENV when it\n differs from the CLI environment alias (notably OpCo registrations). --only\n accepts a comma-separated list of agent IDs after environment selection.\n";
10
10
  export declare function runAgentsCommand(sub: string | undefined, args: ParsedArgs): Promise<number>;
@@ -7,6 +7,22 @@ import { compileAgentSource as compileSource, materializeAgentSource as material
7
7
  import { agentsInitCommand } from './scaffold.js';
8
8
  export { agentsInitCommand };
9
9
  const LOG = '[seq-studio]';
10
+ function requestedAgentIds(args) {
11
+ const only = args.flags.only;
12
+ if (only === undefined)
13
+ return undefined;
14
+ if (typeof only !== 'string') {
15
+ throw new Error('--only must be a comma-separated list of agent IDs');
16
+ }
17
+ const ids = only
18
+ .split(',')
19
+ .map((id) => id.trim())
20
+ .filter(Boolean);
21
+ if (ids.length === 0) {
22
+ throw new Error('--only must include at least one agent ID');
23
+ }
24
+ return [...new Set(ids)];
25
+ }
10
26
  export async function agentsValidateCommand(args) {
11
27
  const { source } = await materialize({ args, requireEnvironment: false });
12
28
  try {
@@ -15,6 +31,7 @@ export async function agentsValidateCommand(args) {
15
31
  directory: source.dir,
16
32
  targetEnvironment: target,
17
33
  deployEnvironments: await deployEnvironmentNames(),
34
+ onlyIds: requestedAgentIds(args),
18
35
  });
19
36
  if (bundle.definitions.length === 0) {
20
37
  console.error(`${LOG} no named agent.ts definitions found`);
@@ -34,6 +51,7 @@ async function deploymentBundle({ args, source, context, }) {
34
51
  directory: source.dir,
35
52
  targetEnvironment: target,
36
53
  deployEnvironments: await deployEnvironmentNames(),
54
+ onlyIds: requestedAgentIds(args),
37
55
  });
38
56
  }
39
57
  /**
@@ -75,6 +93,7 @@ export async function agentsPlanCommand(args) {
75
93
  directory: source.dir,
76
94
  targetEnvironment: target,
77
95
  deployEnvironments: await deployEnvironmentNames(),
96
+ onlyIds: requestedAgentIds(args),
78
97
  });
79
98
  console.log(`${LOG} offline plan: ${bundle.definitions.length} valid definition${bundle.definitions.length === 1 ? '' : 's'}, bundle ${bundle.hash}`);
80
99
  console.log(`${LOG} pass -e <env> for create/update/unchanged live diff`);
@@ -174,15 +193,16 @@ export async function agentsShowCommand(args) {
174
193
  }
175
194
  export const AGENTS_USAGE = `usage:
176
195
  seq-studio agents init <dir> scaffold a typed agent
177
- seq-studio agents validate [--dir d] [--target app] offline compile + validation
178
- seq-studio agents plan [--dir d] [-e <env>] offline bundle plan or live diff
179
- seq-studio agents apply [--dir d] -e <env> [--yes] apply creates/updates; never deletes
196
+ seq-studio agents validate [--dir d] [--target app] [--only ids] offline compile + validation
197
+ seq-studio agents plan [--dir d] [-e <env>] [--only ids] offline bundle plan or live diff
198
+ seq-studio agents apply [--dir d] -e <env> [--only ids] [--yes] apply creates/updates; never deletes
180
199
  seq-studio agents list -e <env> list visible agents
181
200
  seq-studio agents show <id> -e <env> show one agent
182
201
 
183
202
  Source: local --dir (default .), --repo agents/<name>, or --git-url <url>.
184
203
  Use --ref for remote sources. --target selects the deployment APP_ENV when it
185
- differs from the CLI environment alias (notably OpCo registrations).
204
+ differs from the CLI environment alias (notably OpCo registrations). --only
205
+ accepts a comma-separated list of agent IDs after environment selection.
186
206
  `;
187
207
  export async function runAgentsCommand(sub, args) {
188
208
  try {
@@ -10,9 +10,11 @@ export declare function materializeAgentSource({ args, requireEnvironment, }: {
10
10
  source: ResolvedSource;
11
11
  context: CommandContext | null;
12
12
  }>;
13
- export declare function compileAgentSource({ directory, targetEnvironment, deployEnvironments, }: {
13
+ export declare function compileAgentSource({ directory, targetEnvironment, deployEnvironments, onlyIds, }: {
14
14
  directory: string;
15
15
  targetEnvironment?: string;
16
16
  /** Registered deployment environments, so a real env absent from the manifest is not read as a typo. */
17
17
  deployEnvironments?: readonly string[];
18
+ /** Restrict a plan or apply to explicit agent IDs after environment selection. */
19
+ onlyIds?: readonly string[];
18
20
  }): Promise<CompiledAgentBundle>;
@@ -87,10 +87,25 @@ async function compileManifestEntries({ directory, entries, allowDuplicateIds =
87
87
  sources: compiled.sources,
88
88
  };
89
89
  }
90
- export async function compileAgentSource({ directory, targetEnvironment, deployEnvironments = [], }) {
90
+ export async function compileAgentSource({ directory, targetEnvironment, deployEnvironments = [], onlyIds, }) {
91
91
  const manifest = await readManifest(directory);
92
- if (!manifest)
93
- return compileAgentDirectory({ rootDir: directory });
92
+ if (!manifest) {
93
+ const compiled = await compileAgentDirectory({ rootDir: directory });
94
+ if (!onlyIds || onlyIds.length === 0)
95
+ return compiled;
96
+ const requested = new Set(onlyIds);
97
+ const definitions = compiled.definitions.filter((definition) => requested.has(definition.id));
98
+ const missing = [...requested].filter((id) => !definitions.some((definition) => definition.id === id));
99
+ if (missing.length > 0) {
100
+ throw new Error(`Requested agent IDs were not selected: ${missing.join(', ')}`);
101
+ }
102
+ return {
103
+ ...compiled,
104
+ definitions,
105
+ hash: hashAgentBundle({ definitions }),
106
+ sources: compiled.sources.filter((source) => requested.has(source.definition.id)),
107
+ };
108
+ }
94
109
  if (targetEnvironment !== undefined) {
95
110
  assertKnownTargetEnvironment({
96
111
  manifest,
@@ -109,10 +124,18 @@ export async function compileAgentSource({ directory, targetEnvironment, deployE
109
124
  }
110
125
  // With a target: last-wins by id (tenant overrides). Without: every distinct
111
126
  // path, so validate still compiles override sources that lose a collapse.
112
- const entries = selectAgentEntries({
127
+ const selectedEntries = selectAgentEntries({
113
128
  manifest,
114
129
  environment: targetEnvironment,
115
130
  });
131
+ const requested = new Set(onlyIds);
132
+ const entries = requested.size === 0
133
+ ? selectedEntries
134
+ : selectedEntries.filter((entry) => requested.has(entry.id));
135
+ const missing = [...requested].filter((id) => !entries.some((entry) => entry.id === id));
136
+ if (missing.length > 0) {
137
+ throw new Error(`Requested agent IDs were not selected: ${missing.join(', ')}`);
138
+ }
116
139
  return compileManifestEntries({
117
140
  directory,
118
141
  entries,
@@ -9,6 +9,7 @@ import { PREVIEW_DOMAIN } from './preview.js';
9
9
  const MAX_503_RETRIES = 5;
10
10
  const DEFAULT_RETRY_AFTER_SECONDS = 2;
11
11
  const LOG_PREFIX = '[seq-studio]';
12
+ const CF_ACCESS_DOMAIN = 'seqholdings.com';
12
13
  export class AtlasApiError extends Error {
13
14
  status;
14
15
  path;
@@ -42,6 +43,7 @@ async function authenticatedFetch({ baseUrl, init = {}, path, token, }) {
42
43
  redirect: 'manual',
43
44
  headers: {
44
45
  ...previewAccessHeaders(baseUrl),
46
+ ...cfAccessHeaders(baseUrl),
45
47
  ...init.headers,
46
48
  Authorization: `Bearer ${token}`,
47
49
  },
@@ -63,6 +65,33 @@ function previewAccessHeaders(baseUrl) {
63
65
  return {};
64
66
  }
65
67
  }
68
+ /**
69
+ * Cloudflare Access fronts *.seqholdings.com and only bypasses the company
70
+ * network, so a CI runner is turned away at the edge with an HTML "Access
71
+ * Restricted" page before Atlas ever sees the bearer token. A service token
72
+ * gets through the edge; the bearer still authenticates at the app.
73
+ *
74
+ * Scoped to seqholdings.com so the token is never sent to localhost, a tenant
75
+ * domain, or any other host the CLI can be pointed at.
76
+ */
77
+ function cfAccessHeaders(baseUrl) {
78
+ const clientId = process.env.CF_ACCESS_CLIENT_ID?.trim();
79
+ const clientSecret = process.env.CF_ACCESS_CLIENT_SECRET?.trim();
80
+ if (!clientId || !clientSecret)
81
+ return {};
82
+ try {
83
+ const parsed = new URL(baseUrl);
84
+ if (parsed.protocol !== 'https:')
85
+ return {};
86
+ const hostname = parsed.hostname.toLowerCase();
87
+ if (hostname !== CF_ACCESS_DOMAIN && !hostname.endsWith(`.${CF_ACCESS_DOMAIN}`))
88
+ return {};
89
+ return { 'CF-Access-Client-Id': clientId, 'CF-Access-Client-Secret': clientSecret };
90
+ }
91
+ catch {
92
+ return {};
93
+ }
94
+ }
66
95
  function messageFromBody(body, status, statusText) {
67
96
  if (body && typeof body === 'object') {
68
97
  const record = body;
package/dist/auth.js CHANGED
@@ -43,6 +43,8 @@ const SEQUENCE_BUILTIN_ENVS = new Set(['local', 'staging', 'production', 'bankso
43
43
  export function isSequenceAuthEnvName(envName) {
44
44
  return (envName === SEQUENCE_REALM ||
45
45
  SEQUENCE_BUILTIN_ENVS.has(envName) ||
46
+ envName === 'worktree' ||
47
+ envName.startsWith('local:') ||
46
48
  envName === 'preview' ||
47
49
  envName.startsWith('preview:'));
48
50
  }
@@ -198,6 +198,7 @@ entrypoint: handler
198
198
  limits:
199
199
  memory_mb: 256
200
200
  timeout_seconds: 60
201
+ min_instances: 0
201
202
  max_instances: 3
202
203
  invoke_rate_per_minute: 60
203
204
 
@@ -52,6 +52,7 @@ export declare const managedFunctionManifestSchema: z.ZodObject<{
52
52
  limits: z.ZodDefault<z.ZodObject<{
53
53
  memory_mb: z.ZodDefault<z.ZodNumber>;
54
54
  timeout_seconds: z.ZodDefault<z.ZodNumber>;
55
+ min_instances: z.ZodDefault<z.ZodNumber>;
55
56
  max_instances: z.ZodDefault<z.ZodNumber>;
56
57
  invoke_rate_per_minute: z.ZodDefault<z.ZodNumber>;
57
58
  }, z.core.$strip>>;
@@ -83,6 +84,7 @@ export declare const managedFunctionManifestSchema: z.ZodObject<{
83
84
  data: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
84
85
  tables: z.ZodDefault<z.ZodArray<z.ZodString>>;
85
86
  actions: z.ZodDefault<z.ZodArray<z.ZodString>>;
87
+ operations: z.ZodDefault<z.ZodArray<z.ZodString>>;
86
88
  query: z.ZodDefault<z.ZodBoolean>;
87
89
  }, z.core.$strip>>>;
88
90
  }, z.core.$strip>>;
@@ -493,6 +493,12 @@ function validateCapabilityGates({ gates, uses, roles, }) {
493
493
  * Mirrors atlas/src/server/services/managed-functions/manifest.ts.
494
494
  */
495
495
  const SERVICE_ACCOUNT_REF_RE = /^([a-z][a-z0-9-]{1,98}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$/;
496
+ /**
497
+ * GraphQL Name grammar — persisted-operation names exactly as they appear in
498
+ * a namespace's operations manifest. Mirrors
499
+ * atlas/src/server/services/managed-functions/manifest.ts.
500
+ */
501
+ const GRAPHQL_OPERATION_NAME_RE = /^[_A-Za-z][_0-9A-Za-z]*$/;
496
502
  export const managedFunctionManifestSchema = z.object({
497
503
  schema_version: z.literal(1).default(1),
498
504
  function: z.object({
@@ -510,12 +516,23 @@ export const managedFunctionManifestSchema = z.object({
510
516
  .object({
511
517
  memory_mb: z.number().int().min(128).max(2048).default(256),
512
518
  timeout_seconds: z.number().int().min(1).max(540).default(60),
519
+ min_instances: z.number().int().min(0).max(10).default(0),
513
520
  max_instances: z.number().int().min(1).max(10).default(3),
514
521
  invoke_rate_per_minute: z.number().int().min(1).max(600).default(60),
522
+ })
523
+ .superRefine((limits, ctx) => {
524
+ if (limits.min_instances > limits.max_instances) {
525
+ ctx.addIssue({
526
+ code: 'custom',
527
+ message: 'min_instances cannot exceed max_instances',
528
+ path: ['min_instances'],
529
+ });
530
+ }
515
531
  })
516
532
  .default({
517
533
  memory_mb: 256,
518
534
  timeout_seconds: 60,
535
+ min_instances: 0,
519
536
  max_instances: 3,
520
537
  invoke_rate_per_minute: 60,
521
538
  }),
@@ -567,12 +584,13 @@ export const managedFunctionManifestSchema = z.object({
567
584
  gates: capabilityGatesSchema,
568
585
  /**
569
586
  * ORM Data API consumer reach, grouped by namespace: the `tables` this
570
- * function may read, the `actions` it may invoke, and whether raw `query`
571
- * (arbitrary read SQL over the namespace) is allowed — all ON BEHALF OF the
572
- * invoking user. The invoke proxy mints a short-lived token scoped to
573
- * exactly these refs; the Data API re-resolves the user's claims per call,
574
- * so declaring a table never widens what the user could see. Writes go
575
- * through declared actions only raw table writes don't exist.
587
+ * function may read, the v1 `actions` and v2 persisted `operations` it may
588
+ * invoke, and whether raw `query` (arbitrary read SQL over the namespace)
589
+ * is allowed — all ON BEHALF OF the invoking user. The invoke proxy mints
590
+ * a short-lived token scoped to exactly these refs; the Data API
591
+ * re-resolves the user's claims per call, so declaring a table never
592
+ * widens what the user could see. Writes go through declared actions and
593
+ * operations only — raw table writes don't exist.
576
594
  *
577
595
  * Mirrors atlas/src/server/services/managed-functions/manifest.ts — must
578
596
  * stay in sync so `seq-studio` doesn't strip the block before deploy.
@@ -581,6 +599,18 @@ export const managedFunctionManifestSchema = z.object({
581
599
  .record(z.string().regex(/^[a-z][a-z0-9_]{0,40}$/, 'capabilities.data keys are ORM namespace names'), z.object({
582
600
  tables: z.array(z.string().regex(/^[a-z_][a-z0-9_]*$/, 'table names')).max(64).default([]),
583
601
  actions: z.array(z.string().regex(/^[a-z_][a-z0-9_]*$/, 'action names')).max(64).default([]),
602
+ /**
603
+ * ORM v2 persisted operations (GraphQL mutations/actions by name)
604
+ * this function may execute — each mints a `<ns>/ops/<Name>` write
605
+ * ref on the invoke token.
606
+ */
607
+ operations: z
608
+ .array(z
609
+ .string()
610
+ .max(128)
611
+ .regex(GRAPHQL_OPERATION_NAME_RE, 'operation names must match the GraphQL Name grammar'))
612
+ .max(64)
613
+ .default([]),
584
614
  query: z.boolean().default(false),
585
615
  }))
586
616
  .default({}),
@@ -600,9 +630,9 @@ export const managedFunctionManifestSchema = z.object({
600
630
  ctx.addIssue({ code: 'custom', message, path: ['capabilities', 'gates'] });
601
631
  }
602
632
  // A namespace is "reached" when its block declares at least one table,
603
- // action, or raw query — the same rule the server's floor reconciliation
604
- // uses (readManifestDataNamespaces).
605
- const reachesData = Object.values(manifest.capabilities.data).some((block) => block.tables.length > 0 || block.actions.length > 0 || block.query);
633
+ // action, operation, or raw query — the same rule the server's floor
634
+ // reconciliation uses (readManifestDataNamespaces).
635
+ const reachesData = Object.values(manifest.capabilities.data).some((block) => block.tables.length > 0 || block.actions.length > 0 || block.operations.length > 0 || block.query);
606
636
  if (reachesData && manifest.service_account === undefined) {
607
637
  ctx.addIssue({
608
638
  code: 'custom',
@@ -49,6 +49,19 @@ const PIPELINE_USAGE = `usage:
49
49
  banksouth require --approved-by — approvals are explicit even for
50
50
  rollbacks)
51
51
 
52
+ seq-studio pipeline adopt --stage <slug> --ref <sha|branch> -e <env>
53
+ --native-id <id> [--resource-key <key>] [--kind job|dlt_pipeline]
54
+ --approved-by <you> [--old-source-removal-pr <url>]
55
+ [--repo pipelines/<slug>] [--json]
56
+ bind a live Databricks job/pipeline to the stage without recreation
57
+ (SEQ-2449). Always requires --approved-by. Monorepo-declared keys also
58
+ require --old-source-removal-pr.
59
+
60
+ seq-studio pipeline unbind --stage <slug> --ref <sha|branch> -e <env>
61
+ --approved-by <you> [--resource-key <key>]
62
+ [--repo pipelines/<slug>] [--json]
63
+ release an adopted binding; the remote object stays live (never deleted)
64
+
52
65
  seq-studio pipeline run-now --stage <slug> -e <env> [--repo pipelines/<slug>] [--json]
53
66
  fire the stage's active deployment resource (job run-now / DLT
54
67
  start_update) and print the Databricks run URL
@@ -297,6 +310,14 @@ export async function runPipelineCommand(sub, args) {
297
310
  return pipelineInitCommand(args);
298
311
  case 'validate':
299
312
  return pipelineValidateCommand(args);
313
+ case 'adopt': {
314
+ const { pipelineAdoptCommand } = await import('./lifecycle.js');
315
+ return pipelineAdoptCommand(args);
316
+ }
317
+ case 'unbind': {
318
+ const { pipelineUnbindCommand } = await import('./lifecycle.js');
319
+ return pipelineUnbindCommand(args);
320
+ }
300
321
  case 'plan': {
301
322
  const { pipelinePlanCommand } = await import('./lifecycle.js');
302
323
  return pipelinePlanCommand(args);
@@ -56,3 +56,5 @@ export declare function pipelineDeployCommand(args: ParsedArgs): Promise<number>
56
56
  export declare function pipelinePromoteCommand(args: ParsedArgs): Promise<number>;
57
57
  export declare function pipelineRunNowCommand(args: ParsedArgs): Promise<number>;
58
58
  export declare function pipelineRollbackCommand(args: ParsedArgs): Promise<number>;
59
+ export declare function pipelineAdoptCommand(args: ParsedArgs): Promise<number>;
60
+ export declare function pipelineUnbindCommand(args: ParsedArgs): Promise<number>;
@@ -315,6 +315,113 @@ async function pollDeployment({ baseUrl, token, deploymentId, }) {
315
315
  console.error(`${LOG} timed out waiting for deployment ${deploymentId}`);
316
316
  return 1;
317
317
  }
318
+ export async function pipelineAdoptCommand(args) {
319
+ const stage = flagString(args.flags, 'stage');
320
+ const ref = flagString(args.flags, 'ref');
321
+ const nativeId = flagString(args.flags, 'native-id');
322
+ const approvedBy = flagString(args.flags, 'approved-by');
323
+ if (!stage || !ref || !nativeId || !approvedBy) {
324
+ console.error('usage: seq-studio pipeline adopt --stage <slug> --ref <sha|branch> -e <env> ' +
325
+ '--native-id <id> --approved-by <you> [--resource-key <key>] [--kind job|dlt_pipeline] ' +
326
+ '[--old-source-removal-pr <url>] [--repo pipelines/<slug>] [--json]');
327
+ return 1;
328
+ }
329
+ const { env, token } = await envAndToken(args);
330
+ const json = args.flags.json === true || args.flags.json === 'true';
331
+ const repo = flagString(args.flags, 'repo');
332
+ const resourceKey = flagString(args.flags, 'resource-key') ?? stage.replace(/-/g, '_');
333
+ const kind = flagString(args.flags, 'kind') ?? 'job';
334
+ if (kind !== 'job' && kind !== 'dlt_pipeline') {
335
+ console.error(`${LOG} --kind must be job or dlt_pipeline`);
336
+ return 1;
337
+ }
338
+ const oldSourceRemovalPr = flagString(args.flags, 'old-source-removal-pr');
339
+ const stageId = await resolveStageIdBySlug({
340
+ baseUrl: env.url,
341
+ token,
342
+ slug: stage,
343
+ repo,
344
+ });
345
+ try {
346
+ const response = await postJson({
347
+ baseUrl: env.url,
348
+ token,
349
+ path: `/api/data-pipelines/stages/${stageId}/adopt`,
350
+ body: {
351
+ environment: deployEnvironmentForEnv(env),
352
+ ref,
353
+ bindings: [{ resourceKey, nativeId, kind }],
354
+ approvedBy,
355
+ ...(oldSourceRemovalPr ? { oldSourceRemovalPr } : {}),
356
+ },
357
+ });
358
+ if (json) {
359
+ console.log(JSON.stringify(response, null, 2));
360
+ }
361
+ else {
362
+ console.log(`${LOG} enqueued adopt for ${stage} → ${resourceKey}=${nativeId} ` +
363
+ `(trigger=${response.triggerRunId}). Worker binds on Trigger; ` +
364
+ `confirm with plan or stage_resources once the run completes.`);
365
+ }
366
+ return 0;
367
+ }
368
+ catch (error) {
369
+ if (error instanceof AtlasApiError) {
370
+ console.error(`${LOG} adopt failed: ${error.message}`);
371
+ return 1;
372
+ }
373
+ throw error;
374
+ }
375
+ }
376
+ export async function pipelineUnbindCommand(args) {
377
+ const stage = flagString(args.flags, 'stage');
378
+ const ref = flagString(args.flags, 'ref');
379
+ const approvedBy = flagString(args.flags, 'approved-by');
380
+ if (!stage || !ref || !approvedBy) {
381
+ console.error('usage: seq-studio pipeline unbind --stage <slug> --ref <sha|branch> -e <env> ' +
382
+ '--approved-by <you> [--resource-key <key>] [--repo pipelines/<slug>] [--json]');
383
+ return 1;
384
+ }
385
+ const { env, token } = await envAndToken(args);
386
+ const json = args.flags.json === true || args.flags.json === 'true';
387
+ const repo = flagString(args.flags, 'repo');
388
+ const resourceKey = flagString(args.flags, 'resource-key');
389
+ const stageId = await resolveStageIdBySlug({
390
+ baseUrl: env.url,
391
+ token,
392
+ slug: stage,
393
+ repo,
394
+ });
395
+ try {
396
+ const response = await postJson({
397
+ baseUrl: env.url,
398
+ token,
399
+ path: `/api/data-pipelines/stages/${stageId}/unbind`,
400
+ body: {
401
+ environment: deployEnvironmentForEnv(env),
402
+ ref,
403
+ approvedBy,
404
+ ...(resourceKey ? { resourceKeys: [resourceKey] } : {}),
405
+ },
406
+ });
407
+ if (json) {
408
+ console.log(JSON.stringify(response, null, 2));
409
+ }
410
+ else {
411
+ console.log(`${LOG} enqueued unbind for ${stage}` +
412
+ (resourceKey ? ` (${resourceKey})` : '') +
413
+ ` (trigger=${response.triggerRunId}). Remote object is never deleted.`);
414
+ }
415
+ return 0;
416
+ }
417
+ catch (error) {
418
+ if (error instanceof AtlasApiError) {
419
+ console.error(`${LOG} unbind failed: ${error.message}`);
420
+ return 1;
421
+ }
422
+ throw error;
423
+ }
424
+ }
318
425
  /**
319
426
  * Stage identity is `(repo, slug)` — a bare slug can be ambiguous across
320
427
  * Pipelines. `--repo pipelines/<domain>` scopes the lookup server-side; an
@@ -44,6 +44,58 @@ export declare function reposCloneCommand(args: ParsedArgs, deps?: {
44
44
  * so askpass never sends ATLAS_GIT_PAT to an attacker-controlled host.
45
45
  */
46
46
  export declare function normalizeCloneUrl(raw: string, env: ResolvedEnv): string;
47
+ type DiscoveredCliCheck = {
48
+ name: string;
49
+ script?: string;
50
+ command?: string[];
51
+ source: 'autodiscover' | 'ci.json';
52
+ };
53
+ /**
54
+ * Pure discovery shared by `repos ci show` / `import`. Mirrors atlas
55
+ * `discoverCiChecks` / `parseCiJson` so CLI preview matches execution.
56
+ */
57
+ export declare function discoverCliChecks({ ciJson, packageScripts, }: {
58
+ ciJson?: unknown;
59
+ packageScripts?: Record<string, string>;
60
+ }): {
61
+ ok: true;
62
+ checks: DiscoveredCliCheck[];
63
+ } | {
64
+ ok: false;
65
+ error: string;
66
+ };
67
+ /**
68
+ * Load `.seq/ci.json` / `package.json` for CI preview.
69
+ *
70
+ * Matches the runner: only a true 404 means "file absent". Invalid JSON and
71
+ * non-404 API errors fail closed (no silent package.json fallback over a bad
72
+ * `.seq/ci.json`).
73
+ */
74
+ export declare function loadRemoteCiSources({ ctx, repoId, ref, }: {
75
+ ctx: CommandContext;
76
+ repoId: string;
77
+ ref: string;
78
+ }): Promise<{
79
+ ok: true;
80
+ ciJson?: unknown;
81
+ packageScripts?: Record<string, string>;
82
+ } | {
83
+ ok: false;
84
+ error: string;
85
+ }>;
86
+ /**
87
+ * Discover CI checks from a remote repo tip (pure preview — no Settings write).
88
+ * Uses the same autodiscovery rules as the platform runner (PLA-378).
89
+ */
90
+ export declare function reposCiShowCommand(args: ParsedArgs): Promise<number>;
91
+ /** Add a check-run name to Settings `requiredChecks` (merge-blocking). */
92
+ export declare function reposCiRequireCommand(args: ParsedArgs): Promise<number>;
93
+ /**
94
+ * Set requiredChecks to every check discovered on the tip (opt-in bulk require).
95
+ */
96
+ export declare function reposCiImportCommand(args: ParsedArgs): Promise<number>;
97
+ export declare function reposCiCommand(args: ParsedArgs): Promise<number>;
47
98
  export declare function reposDeleteCommand(args: ParsedArgs): Promise<number>;
48
- export declare const REPOS_USAGE = "usage:\n seq-studio repos list -e <env> [--namespace <slug>] [--mine] repos visible on the environment\n seq-studio repos namespaces [create <slug>] -e <env> list or create namespaces\n seq-studio repos show <ns>/<name> -e <env> repo detail (branches, clone URL)\n seq-studio repos create <ns>/<name> -e <env> [--default-branch b] create an empty repo\n seq-studio repos clone <ns>/<name> -e <env> [--ref r] [--out dir] [--force]\n seq-studio repos clone --url <https://\u2026/repos/<id>/git> -e <env> [--ref r] [--out dir]\n seq-studio repos clone --id <uuid> -e <env> [--ref r] [--out dir]\n smart-HTTP when ATLAS_GIT_PAT is set;\n otherwise JSON materialize (<ns>/<name>)\n seq-studio repos pull <ns>/<name> -e <env> [--ref r] [--out dir] [--force]\n materialize the tree at a ref (JSON API)\n seq-studio repos delete <ns>/<name> -e <env> [--yes] delete a repo (confirm prompt)\n\n Flags: -e/--env <env> (required; see: seq-studio envs list)\n\n clone prefers real git clone (PAT via askpass \u2014 never written into the remote\n URL). Without ATLAS_GIT_PAT, <ns>/<name> falls back to the JSON API and prints\n how to get a PAT (seq-studio or Atlas UI /settings/tokens).\n PAT without Auth0 login: use --url from Repositories \u2192 Clone (or --id <uuid>).\n --ref accepts a branch, tag, or commit SHA (SHA \u2192 clone then checkout).\n\n Authenticate JSON API calls with: seq-studio login\n Authenticate git clone/push with: ATLAS_GIT_PAT (from Atlas Settings \u2192 Tokens)\n";
99
+ export declare const REPOS_USAGE = "usage:\n seq-studio repos list -e <env> [--namespace <slug>] [--mine] repos visible on the environment\n seq-studio repos namespaces [create <slug>] -e <env> list or create namespaces\n seq-studio repos show <ns>/<name> -e <env> repo detail (branches, clone URL)\n seq-studio repos create <ns>/<name> -e <env> [--default-branch b] create an empty repo\n seq-studio repos clone <ns>/<name> -e <env> [--ref r] [--out dir] [--force]\n seq-studio repos clone --url <https://\u2026/repos/<id>/git> -e <env> [--ref r] [--out dir]\n seq-studio repos clone --id <uuid> -e <env> [--ref r] [--out dir]\n smart-HTTP when ATLAS_GIT_PAT is set;\n otherwise JSON materialize (<ns>/<name>)\n seq-studio repos pull <ns>/<name> -e <env> [--ref r] [--out dir] [--force]\n materialize the tree at a ref (JSON API)\n seq-studio repos delete <ns>/<name> -e <env> [--yes] delete a repo (confirm prompt)\n seq-studio repos ci show <ns>/<name> -e <env> [--ref r] preview discovered CI checks\n seq-studio repos ci require <ns>/<name> --check <name> -e <env> reserved (refuses write until sandboxed runner)\n seq-studio repos ci import <ns>/<name> -e <env> [--ref r] reserved (refuses write until sandboxed runner)\n\n Flags: -e/--env <env> (required; see: seq-studio envs list)\n\n clone prefers real git clone (PAT via askpass \u2014 never written into the remote\n URL). Without ATLAS_GIT_PAT, <ns>/<name> falls back to the JSON API and prints\n how to get a PAT (seq-studio or Atlas UI /settings/tokens).\n PAT without Auth0 login: use --url from Repositories \u2192 Clone (or --id <uuid>).\n --ref accepts a branch, tag, or commit SHA (SHA \u2192 clone then checkout).\n\n Authenticate JSON API calls with: seq-studio login\n Authenticate git clone/push with: ATLAS_GIT_PAT (from Atlas Settings \u2192 Tokens)\n";
49
100
  export declare function runReposCommand(sub: string | undefined, args: ParsedArgs): Promise<number>;
101
+ export {};
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@sequenceholdings/studio-cli",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
4
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": {