@sequenceholdings/studio-cli 0.1.24 → 0.1.26

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.
@@ -9,6 +9,7 @@ const LOG = '[seq-studio]';
9
9
  const TERMINAL = new Set(['active', 'failed', 'retired']);
10
10
  const POLL_INTERVAL_MS = 2000;
11
11
  const HEARTBEAT_INTERVAL_MS = 20_000;
12
+ const PLAN_TIMEOUT_MS = 30 * 60 * 1000;
12
13
  const FIRST_PARTY_TARGETS = [
13
14
  { id: 'dev', label: 'Development', requiresApproval: false },
14
15
  { id: 'staging', label: 'Staging', requiresApproval: false },
@@ -56,6 +57,9 @@ function flagString(flags, key) {
56
57
  throw new Error(`--${key} requires a value`);
57
58
  return value;
58
59
  }
60
+ function flagOn(flags, key) {
61
+ return flags[key] === true || flags[key] === 'true';
62
+ }
59
63
  export function deployEnvironmentForEnv(env) {
60
64
  return env.name === 'local' ? 'dev' : env.name;
61
65
  }
@@ -101,19 +105,17 @@ export async function pipelinePlanCommand(args) {
101
105
  }
102
106
  let response;
103
107
  try {
104
- response = await postJson({
108
+ response = await enqueueAndWaitForPlan({
105
109
  baseUrl: env.url,
106
110
  token,
107
- path: '/api/data-pipelines/pipelines/plan',
108
- body: { repo, ref, environment: target.id },
111
+ repo,
112
+ ref,
113
+ environment: target.id,
109
114
  });
110
115
  }
111
116
  catch (error) {
112
- if (error instanceof AtlasApiError) {
113
- console.error(`${LOG} plan failed: ${error.message}`);
114
- return 1;
115
- }
116
- throw error;
117
+ console.error(`${LOG} plan failed: ${error instanceof Error ? error.message : String(error)}`);
118
+ return 1;
117
119
  }
118
120
  if (json) {
119
121
  console.log(JSON.stringify(response, null, 2));
@@ -129,6 +131,7 @@ export async function pipelineDeployCommand(args) {
129
131
  const ref = flagString(args.flags, 'ref');
130
132
  const deploymentId = flagString(args.flags, 'deployment-id');
131
133
  const approvedBy = flagString(args.flags, 'approved-by');
134
+ const runNow = flagOn(args.flags, 'run-now');
132
135
  const { env, token } = await envAndToken(args);
133
136
  let target;
134
137
  try {
@@ -144,27 +147,33 @@ export async function pipelineDeployCommand(args) {
144
147
  // Direct execute of a persisted plan.
145
148
  const stageId = flagString(args.flags, 'stage-id');
146
149
  if (!stageId) {
147
- console.error('usage: seq-studio pipeline deploy --deployment-id <id> --stage-id <id> -e <env> [--target <id>] [--approved-by <sub>] [--no-wait]');
150
+ console.error('usage: seq-studio pipeline deploy --deployment-id <id> --stage-id <id> -e <env> [--target <id>] [--approved-by <sub>] [--run-now] [--no-wait]');
148
151
  return 1;
149
152
  }
150
153
  enqueue = await postJson({
151
154
  baseUrl: env.url,
152
155
  token,
153
156
  path: `/api/data-pipelines/stages/${stageId}/deploy`,
154
- body: { deploymentId, environment: target.id, ...(approvedBy ? { approvedBy } : {}) },
157
+ body: {
158
+ deploymentId,
159
+ environment: target.id,
160
+ ...(approvedBy ? { approvedBy } : {}),
161
+ runNow,
162
+ },
155
163
  });
156
164
  }
157
165
  else {
158
166
  if (!repo || !ref) {
159
- console.error('usage: seq-studio pipeline deploy --repo pipelines/<slug> --ref <sha|branch> -e <env> [--target <id>] [--approved-by <sub>] [--no-wait]');
167
+ console.error('usage: seq-studio pipeline deploy --repo pipelines/<slug> --ref <sha|branch> -e <env> [--target <id>] [--approved-by <sub>] [--run-now] [--no-wait]');
160
168
  return 1;
161
169
  }
162
170
  // Plan then deploy the first planning deployment.
163
- const plan = await postJson({
171
+ const plan = await enqueueAndWaitForPlan({
164
172
  baseUrl: env.url,
165
173
  token,
166
- path: '/api/data-pipelines/pipelines/plan',
167
- body: { repo, ref, environment: target.id },
174
+ repo,
175
+ ref,
176
+ environment: target.id,
168
177
  });
169
178
  if (plan.plan.hasDestructive) {
170
179
  console.error(`${LOG} plan has destructive findings — refusing to deploy`);
@@ -190,6 +199,7 @@ export async function pipelineDeployCommand(args) {
190
199
  deploymentId: firstId,
191
200
  environment: target.id,
192
201
  ...(approvedBy ? { approvedBy } : {}),
202
+ runNow,
193
203
  },
194
204
  });
195
205
  }
@@ -250,10 +260,20 @@ export async function pipelinePromoteCommand(args) {
250
260
  }
251
261
  throw error;
252
262
  }
253
- console.log(`${LOG} enqueued promote ${enqueue.deploymentId}`);
263
+ console.log(`${LOG} enqueued promote plan ${enqueue.planRequestId} (trigger=${enqueue.triggerRunId ?? 'n/a'})`);
254
264
  if (args.flags['no-wait'] === true || args.flags['no-wait'] === 'true')
255
265
  return 0;
256
- return pollDeployment({ baseUrl: env.url, token, deploymentId: enqueue.deploymentId });
266
+ const plan = await waitForPlanRequest({
267
+ baseUrl: env.url,
268
+ token,
269
+ planRequestId: enqueue.planRequestId,
270
+ });
271
+ const deploymentId = plan.deploymentIds[plan.stageIds.indexOf(stageId)] ?? plan.deploymentIds[0];
272
+ if (!deploymentId) {
273
+ console.error(`${LOG} promote plan completed without a deployment id`);
274
+ return 1;
275
+ }
276
+ return pollDeployment({ baseUrl: env.url, token, deploymentId });
257
277
  }
258
278
  export async function pipelineRunNowCommand(args) {
259
279
  const stage = flagString(args.flags, 'stage');
@@ -404,6 +424,63 @@ async function pollDeployment({ baseUrl, token, deploymentId, }) {
404
424
  console.error(`${LOG} timed out waiting for deployment ${deploymentId} (${formatElapsed(Date.now() - started)})`);
405
425
  return 1;
406
426
  }
427
+ async function enqueueAndWaitForPlan({ baseUrl, token, repo, ref, environment, }) {
428
+ const enqueue = await postJson({
429
+ baseUrl,
430
+ token,
431
+ path: '/api/data-pipelines/pipelines/plan',
432
+ body: { repo, ref, environment },
433
+ });
434
+ console.log(`${LOG} enqueued plan ${enqueue.planRequestId} (trigger=${enqueue.triggerRunId ?? 'n/a'})`);
435
+ const detail = await waitForPlanRequest({
436
+ baseUrl,
437
+ token,
438
+ planRequestId: enqueue.planRequestId,
439
+ });
440
+ if (!detail.plan || !detail.text) {
441
+ throw new Error(`plan request ${detail.planRequestId} succeeded without a plan result`);
442
+ }
443
+ return {
444
+ plan: detail.plan,
445
+ deploymentIds: detail.deploymentIds,
446
+ stageIds: detail.stageIds,
447
+ text: detail.text,
448
+ };
449
+ }
450
+ async function waitForPlanRequest({ baseUrl, token, planRequestId, }) {
451
+ const started = Date.now();
452
+ let lastStatus = null;
453
+ let lastLoggedAt = started;
454
+ while (Date.now() - started < PLAN_TIMEOUT_MS) {
455
+ const detail = await getJson({
456
+ baseUrl,
457
+ token,
458
+ path: `/api/data-pipelines/plan-requests/${planRequestId}`,
459
+ });
460
+ const elapsed = Date.now() - started;
461
+ const statusLabel = detail.statusDetail
462
+ ? `${detail.status} — ${detail.statusDetail}`
463
+ : detail.status;
464
+ if (detail.status === 'failed') {
465
+ throw new Error(`plan request ${detail.planRequestId} failed after ${formatElapsed(elapsed)}: ` +
466
+ `${detail.statusDetail ?? 'unknown error'}`);
467
+ }
468
+ if (detail.status === 'succeeded')
469
+ return detail;
470
+ if (statusLabel !== lastStatus) {
471
+ console.log(`${LOG} plan ${detail.planRequestId}: ${statusLabel} (${formatElapsed(elapsed)})`);
472
+ lastStatus = statusLabel;
473
+ lastLoggedAt = Date.now();
474
+ }
475
+ else if (Date.now() - lastLoggedAt >= HEARTBEAT_INTERVAL_MS) {
476
+ console.log(`${LOG} plan ${detail.planRequestId}: still ${statusLabel} (${formatElapsed(elapsed)})`);
477
+ lastLoggedAt = Date.now();
478
+ }
479
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
480
+ }
481
+ throw new Error(`timed out waiting for plan request ${planRequestId} ` +
482
+ `(${formatElapsed(Date.now() - started)})`);
483
+ }
407
484
  export async function pipelineAdoptCommand(args) {
408
485
  const stage = flagString(args.flags, 'stage');
409
486
  const ref = flagString(args.flags, 'ref');
package/dist/preview.d.ts CHANGED
@@ -6,10 +6,11 @@
6
6
  * hand-editing `~/.config/lattice/config.toml`. See
7
7
  * `docs/preview-environments.md` for the full lifecycle.
8
8
  *
9
- * SLUG ALGORITHM — must stay byte-for-byte in sync with the three other
9
+ * SLUG ALGORITHM — must stay byte-for-byte in sync with the other
10
10
  * places that compute the same slug from a branch name:
11
- * - `.github/workflows/preview-deploy.yml` (`sed 's/[^a-zA-Z0-9-]/-/g' | tr '[:upper:]' '[:lower:]'`)
12
- * - `atlas/src/server/db.ts:sanitize` (`replace(/[^a-zA-Z0-9-]/g, '-').toLowerCase()`)
11
+ * - `.github/workflows/preview-deploy.yml` (replace non-alnum, lowercase, trim hyphens)
12
+ * - `.github/workflows/trigger-preview.yml` (same bash pipeline)
13
+ * - `atlas/src/server/db.ts:sanitize` (`.replace(/[^a-zA-Z0-9-]/g, '-').toLowerCase().replace(/^-+|-+$/g, '')`)
13
14
  * - `databricks/lakebase/atlas-db/pr-lifecycle.ts:sanitize`
14
15
  * Changing it here without changing those produces a host that does NOT
15
16
  * match what `preview-deploy.yml` actually deployed.
package/dist/preview.js CHANGED
@@ -8,10 +8,11 @@ import { promisify } from 'node:util';
8
8
  * hand-editing `~/.config/lattice/config.toml`. See
9
9
  * `docs/preview-environments.md` for the full lifecycle.
10
10
  *
11
- * SLUG ALGORITHM — must stay byte-for-byte in sync with the three other
11
+ * SLUG ALGORITHM — must stay byte-for-byte in sync with the other
12
12
  * places that compute the same slug from a branch name:
13
- * - `.github/workflows/preview-deploy.yml` (`sed 's/[^a-zA-Z0-9-]/-/g' | tr '[:upper:]' '[:lower:]'`)
14
- * - `atlas/src/server/db.ts:sanitize` (`replace(/[^a-zA-Z0-9-]/g, '-').toLowerCase()`)
13
+ * - `.github/workflows/preview-deploy.yml` (replace non-alnum, lowercase, trim hyphens)
14
+ * - `.github/workflows/trigger-preview.yml` (same bash pipeline)
15
+ * - `atlas/src/server/db.ts:sanitize` (`.replace(/[^a-zA-Z0-9-]/g, '-').toLowerCase().replace(/^-+|-+$/g, '')`)
15
16
  * - `databricks/lakebase/atlas-db/pr-lifecycle.ts:sanitize`
16
17
  * Changing it here without changing those produces a host that does NOT
17
18
  * match what `preview-deploy.yml` actually deployed.
@@ -45,7 +46,7 @@ export const PREVIEW_PROTECTED_SLUGS = [
45
46
  export const MAX_LABEL_LENGTH = 63;
46
47
  /** Apply the canonical branch → slug transform. */
47
48
  export function previewSlug(branchOrSlug) {
48
- return branchOrSlug.replace(/[^a-zA-Z0-9-]/g, '-').toLowerCase();
49
+ return branchOrSlug.replace(/[^a-zA-Z0-9-]/g, '-').toLowerCase().replace(/^-+|-+$/g, '');
49
50
  }
50
51
  /** The `studio-atlas-git-<slug>` DNS label Vercel assigns the preview. */
51
52
  export function previewLabel(slug) {
@@ -96,6 +96,6 @@ export declare function reposCiRequireCommand(args: ParsedArgs): Promise<number>
96
96
  export declare function reposCiImportCommand(args: ParsedArgs): Promise<number>;
97
97
  export declare function reposCiCommand(args: ParsedArgs): Promise<number>;
98
98
  export declare function reposDeleteCommand(args: ParsedArgs): Promise<number>;
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";
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 a repo seeded with a Sequence app README\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";
100
100
  export declare function runReposCommand(sub: string | undefined, args: ParsedArgs): Promise<number>;
101
101
  export {};
@@ -748,7 +748,7 @@ export const REPOS_USAGE = `usage:
748
748
  seq-studio repos list -e <env> [--namespace <slug>] [--mine] repos visible on the environment
749
749
  seq-studio repos namespaces [create <slug>] -e <env> list or create namespaces
750
750
  seq-studio repos show <ns>/<name> -e <env> repo detail (branches, clone URL)
751
- seq-studio repos create <ns>/<name> -e <env> [--default-branch b] create an empty repo
751
+ seq-studio repos create <ns>/<name> -e <env> [--default-branch b] create a repo seeded with a Sequence app README
752
752
  seq-studio repos clone <ns>/<name> -e <env> [--ref r] [--out dir] [--force]
753
753
  seq-studio repos clone --url <https://…/repos/<id>/git> -e <env> [--ref r] [--out dir]
754
754
  seq-studio repos clone --id <uuid> -e <env> [--ref r] [--out dir]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sequenceholdings/studio-cli",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "description": "Unified Sequence Studio CLI — `seq-studio init` / `add` / `deploy` (app monorepos), `seq-studio agents`, `seq-studio process` (Lattice), `seq-studio artifact`, `seq-studio functions` / `secrets`, `seq-studio repos`, and `seq-studio auth pat`. Includes Auth0 browser login shared with seqapi.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -36,13 +36,13 @@
36
36
  "README.md"
37
37
  ],
38
38
  "dependencies": {
39
- "js-yaml": "^4.1.1",
39
+ "js-yaml": "^4.3.1",
40
40
  "smol-toml": "^1.4.2",
41
41
  "tsx": "^4.20.3",
42
42
  "zod": "^4.1.13",
43
- "@sequenceholdings/artifact-studio": "0.2.1",
44
- "@sequenceholdings/lattice": "0.1.2",
45
- "@sequenceholdings/agent-spec": "0.1.1"
43
+ "@sequenceholdings/agent-spec": "0.1.2",
44
+ "@sequenceholdings/artifact-studio": "0.2.2",
45
+ "@sequenceholdings/lattice": "0.1.2"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "@sequenceholdings/orm": "0.1.3",
@@ -61,8 +61,8 @@
61
61
  "@types/node": "^22.0.0",
62
62
  "typescript": "^5.6.0",
63
63
  "vitest": "^4.1.5",
64
- "@sequenceholdings/pipeline-spec": "0.1.0",
65
- "@sequenceholdings/orm": "0.1.3"
64
+ "@sequenceholdings/orm": "0.1.3",
65
+ "@sequenceholdings/pipeline-spec": "0.1.0"
66
66
  },
67
67
  "engines": {
68
68
  "node": ">=20"