@sequenceholdings/studio-cli 0.1.22 → 0.1.25

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.
@@ -5,9 +5,35 @@
5
5
  import { getAccessToken, NotLoggedInError } from '../auth.js';
6
6
  import { AtlasApiError, getJson, postJson } from '../atlas-client.js';
7
7
  import { resolveEnvWithDiscovery } from '../config.js';
8
- import { isFullSha, requiresPinnedSha } from './pinning.js';
9
8
  const LOG = '[seq-studio]';
10
9
  const TERMINAL = new Set(['active', 'failed', 'retired']);
10
+ const POLL_INTERVAL_MS = 2000;
11
+ const HEARTBEAT_INTERVAL_MS = 20_000;
12
+ const PLAN_TIMEOUT_MS = 30 * 60 * 1000;
13
+ const FIRST_PARTY_TARGETS = [
14
+ { id: 'dev', label: 'Development', requiresApproval: false },
15
+ { id: 'staging', label: 'Staging', requiresApproval: false },
16
+ { id: 'production', label: 'Production', requiresApproval: true },
17
+ { id: 'banksouth', label: 'BankSouth', requiresApproval: true },
18
+ ];
19
+ function formatElapsed(ms) {
20
+ const totalSec = Math.floor(ms / 1000);
21
+ if (totalSec < 60)
22
+ return `${totalSec}s`;
23
+ const min = Math.floor(totalSec / 60);
24
+ const sec = totalSec % 60;
25
+ return sec > 0 ? `${min}m ${sec}s` : `${min}m`;
26
+ }
27
+ function deploymentProgressSuffix(detail) {
28
+ if (detail.triggerRunId)
29
+ return ` (trigger ${detail.triggerRunId})`;
30
+ return '';
31
+ }
32
+ function deploymentStatusLabel(detail) {
33
+ if (detail.statusDetail)
34
+ return `${detail.status} — ${detail.statusDetail}`;
35
+ return detail.status;
36
+ }
11
37
  async function envAndToken(args) {
12
38
  const requested = typeof args.flags.e === 'string' ? args.flags.e : typeof args.flags.env === 'string' ? args.flags.env : undefined;
13
39
  if (!requested) {
@@ -31,21 +57,35 @@ function flagString(flags, key) {
31
57
  throw new Error(`--${key} requires a value`);
32
58
  return value;
33
59
  }
34
- /**
35
- * The CLI's local dev-loop env is named `local` — chosen so `-e local` lines
36
- * up with `seqapi -e local` / `artifact-studio --env local` (see `config.ts`
37
- * `BUILT_IN_ENV_URLS`). There is no `local` in the server's deploy-lifecycle
38
- * enum (`DEPLOY_ENVIRONMENTS` in `atlas/src/server/services/data-pipelines/schema.ts`
39
- * is `dev | staging | production | banksouth`); the server's name for that
40
- * same developer-loop target is `dev` (`targetFactsForEnvironment` treats
41
- * `dev` as the one target exempt from a `lakebase_branch` binding). Map at
42
- * this one boundary — every lifecycle request body funnels through here —
43
- * so the CLI never sends the wire-invalid `local` and every other env name
44
- * passes through unchanged.
45
- */
60
+ function flagOn(flags, key) {
61
+ return flags[key] === true || flags[key] === 'true';
62
+ }
46
63
  export function deployEnvironmentForEnv(env) {
47
64
  return env.name === 'local' ? 'dev' : env.name;
48
65
  }
66
+ async function resolvePipelineTarget({ args, env, token, }) {
67
+ const requested = flagString(args.flags, 'target');
68
+ const inferredId = requested ?? deployEnvironmentForEnv(env);
69
+ if (!requested) {
70
+ const firstPartyTarget = FIRST_PARTY_TARGETS.find((target) => target.id === inferredId);
71
+ if (firstPartyTarget)
72
+ return firstPartyTarget;
73
+ }
74
+ const response = await getJson({
75
+ baseUrl: env.url,
76
+ token,
77
+ path: '/api/data-pipelines/targets',
78
+ });
79
+ const targets = response.targets;
80
+ const matchingTarget = targets.find((target) => target.id === inferredId);
81
+ if (matchingTarget)
82
+ return matchingTarget;
83
+ if (!requested && targets.length === 1)
84
+ return targets[0];
85
+ const available = targets.map((target) => target.id).join(', ') || 'none';
86
+ throw new Error(`could not select a pipeline target for '${env.name}'; available targets: ${available}. ` +
87
+ 'Pass --target <id> explicitly.');
88
+ }
49
89
  export async function pipelinePlanCommand(args) {
50
90
  const repo = flagString(args.flags, 'repo');
51
91
  const ref = flagString(args.flags, 'ref');
@@ -55,21 +95,27 @@ export async function pipelinePlanCommand(args) {
55
95
  }
56
96
  const { env, token } = await envAndToken(args);
57
97
  const json = args.flags.json === true || args.flags.json === 'true';
98
+ let target;
99
+ try {
100
+ target = await resolvePipelineTarget({ args, env, token });
101
+ }
102
+ catch (error) {
103
+ console.error(`${LOG} plan failed: ${error instanceof Error ? error.message : String(error)}`);
104
+ return 1;
105
+ }
58
106
  let response;
59
107
  try {
60
- response = await postJson({
108
+ response = await enqueueAndWaitForPlan({
61
109
  baseUrl: env.url,
62
110
  token,
63
- path: '/api/data-pipelines/pipelines/plan',
64
- body: { repo, ref, environment: deployEnvironmentForEnv(env) },
111
+ repo,
112
+ ref,
113
+ environment: target.id,
65
114
  });
66
115
  }
67
116
  catch (error) {
68
- if (error instanceof AtlasApiError) {
69
- console.error(`${LOG} plan failed: ${error.message}`);
70
- return 1;
71
- }
72
- throw error;
117
+ console.error(`${LOG} plan failed: ${error instanceof Error ? error.message : String(error)}`);
118
+ return 1;
73
119
  }
74
120
  if (json) {
75
121
  console.log(JSON.stringify(response, null, 2));
@@ -85,9 +131,14 @@ export async function pipelineDeployCommand(args) {
85
131
  const ref = flagString(args.flags, 'ref');
86
132
  const deploymentId = flagString(args.flags, 'deployment-id');
87
133
  const approvedBy = flagString(args.flags, 'approved-by');
134
+ const runNow = flagOn(args.flags, 'run-now');
88
135
  const { env, token } = await envAndToken(args);
89
- if (requiresPinnedSha(env.name) && ref && !isFullSha(ref)) {
90
- console.error(`${LOG} deploy to ${env.name} requires a pinned 40-hex commit SHA (got '${ref}')`);
136
+ let target;
137
+ try {
138
+ target = await resolvePipelineTarget({ args, env, token });
139
+ }
140
+ catch (error) {
141
+ console.error(`${LOG} deploy failed: ${error instanceof Error ? error.message : String(error)}`);
91
142
  return 1;
92
143
  }
93
144
  let enqueue;
@@ -96,27 +147,33 @@ export async function pipelineDeployCommand(args) {
96
147
  // Direct execute of a persisted plan.
97
148
  const stageId = flagString(args.flags, 'stage-id');
98
149
  if (!stageId) {
99
- console.error('usage: seq-studio pipeline deploy --deployment-id <id> --stage-id <id> -e <env> [--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]');
100
151
  return 1;
101
152
  }
102
153
  enqueue = await postJson({
103
154
  baseUrl: env.url,
104
155
  token,
105
156
  path: `/api/data-pipelines/stages/${stageId}/deploy`,
106
- body: { deploymentId, ...(approvedBy ? { approvedBy } : {}) },
157
+ body: {
158
+ deploymentId,
159
+ environment: target.id,
160
+ ...(approvedBy ? { approvedBy } : {}),
161
+ runNow,
162
+ },
107
163
  });
108
164
  }
109
165
  else {
110
166
  if (!repo || !ref) {
111
- console.error('usage: seq-studio pipeline deploy --repo pipelines/<slug> --ref <sha> -e <env> [--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]');
112
168
  return 1;
113
169
  }
114
170
  // Plan then deploy the first planning deployment.
115
- const plan = await postJson({
171
+ const plan = await enqueueAndWaitForPlan({
116
172
  baseUrl: env.url,
117
173
  token,
118
- path: '/api/data-pipelines/pipelines/plan',
119
- body: { repo, ref, environment: deployEnvironmentForEnv(env) },
174
+ repo,
175
+ ref,
176
+ environment: target.id,
120
177
  });
121
178
  if (plan.plan.hasDestructive) {
122
179
  console.error(`${LOG} plan has destructive findings — refusing to deploy`);
@@ -140,7 +197,9 @@ export async function pipelineDeployCommand(args) {
140
197
  path: `/api/data-pipelines/stages/${stageId}/deploy`,
141
198
  body: {
142
199
  deploymentId: firstId,
200
+ environment: target.id,
143
201
  ...(approvedBy ? { approvedBy } : {}),
202
+ runNow,
144
203
  },
145
204
  });
146
205
  }
@@ -168,8 +227,16 @@ export async function pipelinePromoteCommand(args) {
168
227
  return 1;
169
228
  }
170
229
  const { env, token } = await envAndToken(args);
171
- if (requiresPinnedSha(env.name) && !approvedBy) {
172
- console.error(`${LOG} promote to ${env.name} requires --approved-by <your sub/email> (approvals are self-recorded)`);
230
+ let target;
231
+ try {
232
+ target = await resolvePipelineTarget({ args, env, token });
233
+ }
234
+ catch (error) {
235
+ console.error(`${LOG} promote failed: ${error instanceof Error ? error.message : String(error)}`);
236
+ return 1;
237
+ }
238
+ if (target.requiresApproval && !approvedBy) {
239
+ console.error(`${LOG} promote to ${target.id} requires --approved-by <your sub/email> (approvals are self-recorded)`);
173
240
  return 1;
174
241
  }
175
242
  const stageId = await resolveStageIdBySlug({ baseUrl: env.url, token, slug: stage, repo });
@@ -181,7 +248,7 @@ export async function pipelinePromoteCommand(args) {
181
248
  path: `/api/data-pipelines/stages/${stageId}/promote`,
182
249
  body: {
183
250
  version,
184
- environment: deployEnvironmentForEnv(env),
251
+ environment: target.id,
185
252
  ...(approvedBy ? { approvedBy } : {}),
186
253
  },
187
254
  });
@@ -193,10 +260,20 @@ export async function pipelinePromoteCommand(args) {
193
260
  }
194
261
  throw error;
195
262
  }
196
- console.log(`${LOG} enqueued promote ${enqueue.deploymentId}`);
263
+ console.log(`${LOG} enqueued promote plan ${enqueue.planRequestId} (trigger=${enqueue.triggerRunId ?? 'n/a'})`);
197
264
  if (args.flags['no-wait'] === true || args.flags['no-wait'] === 'true')
198
265
  return 0;
199
- 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 });
200
277
  }
201
278
  export async function pipelineRunNowCommand(args) {
202
279
  const stage = flagString(args.flags, 'stage');
@@ -207,6 +284,14 @@ export async function pipelineRunNowCommand(args) {
207
284
  }
208
285
  const { env, token } = await envAndToken(args);
209
286
  const json = args.flags.json === true || args.flags.json === 'true';
287
+ let target;
288
+ try {
289
+ target = await resolvePipelineTarget({ args, env, token });
290
+ }
291
+ catch (error) {
292
+ console.error(`${LOG} run-now failed: ${error instanceof Error ? error.message : String(error)}`);
293
+ return 1;
294
+ }
210
295
  let stageId;
211
296
  try {
212
297
  stageId = await resolveStageIdBySlug({ baseUrl: env.url, token, slug: stage, repo });
@@ -221,7 +306,7 @@ export async function pipelineRunNowCommand(args) {
221
306
  baseUrl: env.url,
222
307
  token,
223
308
  path: `/api/data-pipelines/stages/${stageId}/run`,
224
- body: { environment: deployEnvironmentForEnv(env) },
309
+ body: { environment: target.id },
225
310
  });
226
311
  }
227
312
  catch (error) {
@@ -235,7 +320,7 @@ export async function pipelineRunNowCommand(args) {
235
320
  console.log(JSON.stringify(response, null, 2));
236
321
  }
237
322
  else {
238
- console.log(`${LOG} run-now ${stage} → ${deployEnvironmentForEnv(env)}`);
323
+ console.log(`${LOG} run-now ${stage} → ${target.id}`);
239
324
  console.log(`${LOG} invocation ${response.invocationId} (${response.status})`);
240
325
  console.log(`${LOG} ${response.databricks_url}`);
241
326
  }
@@ -250,8 +335,16 @@ export async function pipelineRollbackCommand(args) {
250
335
  return 1;
251
336
  }
252
337
  const { env, token } = await envAndToken(args);
253
- if (requiresPinnedSha(env.name) && !approvedBy) {
254
- console.error(`${LOG} rollback in ${env.name} requires --approved-by <your sub/email> (approvals are explicit even for rollbacks)`);
338
+ let target;
339
+ try {
340
+ target = await resolvePipelineTarget({ args, env, token });
341
+ }
342
+ catch (error) {
343
+ console.error(`${LOG} rollback failed: ${error instanceof Error ? error.message : String(error)}`);
344
+ return 1;
345
+ }
346
+ if (target.requiresApproval && !approvedBy) {
347
+ console.error(`${LOG} rollback in ${target.id} requires --approved-by <your sub/email> (approvals are explicit even for rollbacks)`);
255
348
  return 1;
256
349
  }
257
350
  const stageId = await resolveStageIdBySlug({ baseUrl: env.url, token, slug: stage, repo });
@@ -262,7 +355,7 @@ export async function pipelineRollbackCommand(args) {
262
355
  token,
263
356
  path: `/api/data-pipelines/stages/${stageId}/rollback`,
264
357
  body: {
265
- environment: deployEnvironmentForEnv(env),
358
+ environment: target.id,
266
359
  ...(approvedBy ? { approvedBy } : {}),
267
360
  },
268
361
  });
@@ -295,26 +388,99 @@ function printFindingsTable(findings) {
295
388
  async function pollDeployment({ baseUrl, token, deploymentId, }) {
296
389
  const started = Date.now();
297
390
  const timeoutMs = 15 * 60 * 1000;
391
+ let lastLoggedLabel = null;
392
+ let lastLogAt = started;
298
393
  while (Date.now() - started < timeoutMs) {
299
394
  const detail = await getJson({
300
395
  baseUrl,
301
396
  token,
302
397
  path: `/api/data-pipelines/deployments/${deploymentId}`,
303
398
  });
304
- console.log(`${LOG} deployment ${deploymentId}: ${detail.status}`);
399
+ const elapsedMs = Date.now() - started;
305
400
  if (TERMINAL.has(detail.status)) {
401
+ const elapsed = formatElapsed(elapsedMs);
402
+ const progressSuffix = deploymentProgressSuffix(detail);
306
403
  if (detail.status === 'active') {
307
- console.log(`${LOG} deploy succeeded`);
404
+ console.log(`${LOG} deployment ${deploymentId} succeeded (${elapsed})${progressSuffix}`);
308
405
  return 0;
309
406
  }
310
- console.error(`${LOG} deploy ended '${detail.status}': ${detail.statusDetail ?? ''}`);
407
+ const detailText = detail.statusDetail ? `: ${detail.statusDetail}` : '';
408
+ console.error(`${LOG} deployment ${deploymentId} ended ${detail.status} (${elapsed})${detailText}${progressSuffix}`);
311
409
  return 1;
312
410
  }
313
- await new Promise((r) => setTimeout(r, 2000));
411
+ const progressSuffix = deploymentProgressSuffix(detail);
412
+ const statusLabel = deploymentStatusLabel(detail);
413
+ if (statusLabel !== lastLoggedLabel) {
414
+ console.log(`${LOG} deployment ${deploymentId}: ${statusLabel} (${formatElapsed(elapsedMs)})${progressSuffix}`);
415
+ lastLoggedLabel = statusLabel;
416
+ lastLogAt = Date.now();
417
+ }
418
+ else if (Date.now() - lastLogAt >= HEARTBEAT_INTERVAL_MS) {
419
+ console.log(`${LOG} deployment ${deploymentId}: still ${statusLabel} (${formatElapsed(elapsedMs)})${progressSuffix}`);
420
+ lastLogAt = Date.now();
421
+ }
422
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
314
423
  }
315
- console.error(`${LOG} timed out waiting for deployment ${deploymentId}`);
424
+ console.error(`${LOG} timed out waiting for deployment ${deploymentId} (${formatElapsed(Date.now() - started)})`);
316
425
  return 1;
317
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
+ }
318
484
  export async function pipelineAdoptCommand(args) {
319
485
  const stage = flagString(args.flags, 'stage');
320
486
  const ref = flagString(args.flags, 'ref');
@@ -328,6 +494,14 @@ export async function pipelineAdoptCommand(args) {
328
494
  }
329
495
  const { env, token } = await envAndToken(args);
330
496
  const json = args.flags.json === true || args.flags.json === 'true';
497
+ let target;
498
+ try {
499
+ target = await resolvePipelineTarget({ args, env, token });
500
+ }
501
+ catch (error) {
502
+ console.error(`${LOG} adopt failed: ${error instanceof Error ? error.message : String(error)}`);
503
+ return 1;
504
+ }
331
505
  const repo = flagString(args.flags, 'repo');
332
506
  const resourceKey = flagString(args.flags, 'resource-key') ?? stage.replace(/-/g, '_');
333
507
  const kind = flagString(args.flags, 'kind') ?? 'job';
@@ -348,7 +522,7 @@ export async function pipelineAdoptCommand(args) {
348
522
  token,
349
523
  path: `/api/data-pipelines/stages/${stageId}/adopt`,
350
524
  body: {
351
- environment: deployEnvironmentForEnv(env),
525
+ environment: target.id,
352
526
  ref,
353
527
  bindings: [{ resourceKey, nativeId, kind }],
354
528
  approvedBy,
@@ -384,6 +558,14 @@ export async function pipelineUnbindCommand(args) {
384
558
  }
385
559
  const { env, token } = await envAndToken(args);
386
560
  const json = args.flags.json === true || args.flags.json === 'true';
561
+ let target;
562
+ try {
563
+ target = await resolvePipelineTarget({ args, env, token });
564
+ }
565
+ catch (error) {
566
+ console.error(`${LOG} unbind failed: ${error instanceof Error ? error.message : String(error)}`);
567
+ return 1;
568
+ }
387
569
  const repo = flagString(args.flags, 'repo');
388
570
  const resourceKey = flagString(args.flags, 'resource-key');
389
571
  const stageId = await resolveStageIdBySlug({
@@ -398,7 +580,7 @@ export async function pipelineUnbindCommand(args) {
398
580
  token,
399
581
  path: `/api/data-pipelines/stages/${stageId}/unbind`,
400
582
  body: {
401
- environment: deployEnvironmentForEnv(env),
583
+ environment: target.id,
402
584
  ref,
403
585
  approvedBy,
404
586
  ...(resourceKey ? { resourceKeys: [resourceKey] } : {}),
@@ -116,6 +116,7 @@ const SERVING_BODY = `
116
116
  source:
117
117
  stage: my-gold-stage
118
118
  output: gold.my_table
119
+ # catalog: src_example # optional; omit to use the deploy catalog
119
120
  projection:
120
121
  # The served contract (inline columns or schema_ref):
121
122
  columns:
@@ -139,7 +140,8 @@ expose:
139
140
  # table: my_table
140
141
  # mode: referenced
141
142
  `;
142
- const ENTRYPOINT_STUB = `"""Entrypoint stub for the {{name}} stage.
143
+ const ENTRYPOINT_STUB = `# Databricks notebook source
144
+ """Entrypoint stub for the {{name}} stage.
143
145
 
144
146
  The platform invokes this per the stage spec ({{name}}.stage.yml).
145
147
  Decoders and transforms stay pure — bronze materialization, envelope
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) {
@@ -1,5 +1,7 @@
1
1
  import type { ParsedArgs } from '../process/commands.js';
2
2
  export declare const LOG = "[seq-studio]";
3
+ /** Drop one trailing LF or CRLF so Windows-authored --from-file values match Unix. */
4
+ export declare function stripOneTrailingNewline(value: string): string;
3
5
  export declare function secretsCreateCommand(args: ParsedArgs): Promise<number>;
4
6
  export declare function secretsSetCommand(args: ParsedArgs): Promise<number>;
5
7
  export declare function secretsListCommand(args: ParsedArgs): Promise<number>;
@@ -20,5 +22,5 @@ export declare function secretsSetDefaultCommand(args: ParsedArgs): Promise<numb
20
22
  * UI: pin + redeploy the function's active version / pin only / cancel.
21
23
  */
22
24
  export declare function secretsPinCommand(args: ParsedArgs): Promise<number>;
23
- export declare const SECRETS_USAGE = "usage:\n seq-studio secrets create <NAME> -e <env> [--description text] register an org-owned secret\n seq-studio secrets set <NAME> -e <env> set the shared default value (write-only)\n seq-studio secrets list -e <env> secrets you can see (never values)\n seq-studio secrets attach <NAME> --fn <slug> -e <env> mount default on a function env var\n seq-studio secrets detach <NAME> --fn <slug> -e <env> remove attachment\n seq-studio secrets apply --from-env-file .env -e <env> push defaults + attach (keys default from manifest)\n seq-studio secrets versions <NAME> -e <env> value version history (never values)\n seq-studio secrets set-default <NAME> [version] -e <env> point the default at a prior version (editor)\n seq-studio secrets pin <NAME> --fn <slug> [version] -e <env> pin one function to a version (sticky; --unpin clears)\n\n Note: for the common deploy loop, secrets declared in managed-function.yml are\n reconciled automatically by `seq-studio functions deploy` using a local .env.\n Use `secrets` commands for CI (no .env), bulk/multi-function ops, or write-only\n value changes without a redeploy.\n\n Flags: -e/--env <env> (required; see: seq-studio envs list)\n --fn <slug> \u00B7 --env-var <NAME> \u00B7 --yes\n --functions <f1,f2> \u00B7 --keys <K1,K2> \u00B7 --all (override key selection)\n set-default: [version|version-row-id] (defaults to the most recent non-default) \u00B7 --yes\n pin: [version|version-row-id] (defaults to the current default) \u00B7 --unpin \u00B7 --yes (redeploy) \u00B7 --yes --no-redeploy\n";
25
+ export declare const SECRETS_USAGE = "usage:\n seq-studio secrets create <NAME> -e <env> [--org <slug>] [--description text]\n register an org-owned secret\n seq-studio secrets set <NAME> -e <env> [--org <slug>] [--from-file <path>]\n set the shared default value (write-only)\n seq-studio secrets list -e <env> secrets you can see (never values)\n seq-studio secrets attach <NAME> --fn <slug> -e <env> [--org <slug>]\n mount default on a function env var\n seq-studio secrets detach <NAME> --fn <slug> -e <env> [--org <slug>]\n remove attachment\n seq-studio secrets apply --from-env-file .env -e <env> push defaults + attach (keys default from manifest)\n seq-studio secrets versions <NAME> -e <env> [--org <slug>] value version history (never values)\n seq-studio secrets set-default <NAME> [version] -e <env> [--org <slug>]\n point the default at a prior version (editor)\n seq-studio secrets pin <NAME> --fn <slug> [version] -e <env> [--org <slug>]\n pin one function to a version (sticky; --unpin clears)\n\n Note: for the common deploy loop, secrets declared in managed-function.yml are\n reconciled automatically by `seq-studio functions deploy` using a local .env.\n Use `secrets` commands for CI (no .env), bulk/multi-function ops, or write-only\n value changes without a redeploy.\n\n Flags: -e/--env <env> (required; see: seq-studio envs list)\n --org <slug> (target/select managed-scope org; required when a name exists in multiple orgs)\n set: --from-file <path> (non-interactive; file is not echoed)\n --fn <slug> \u00B7 --env-var <NAME> \u00B7 --yes\n --functions <f1,f2> \u00B7 --keys <K1,K2> \u00B7 --all (override key selection)\n set-default: [version|version-row-id] (defaults to the most recent non-default) \u00B7 --yes\n pin: [version|version-row-id] (defaults to the current default) \u00B7 --unpin \u00B7 --yes (redeploy) \u00B7 --yes --no-redeploy\n";
24
26
  export declare function runSecretsCommand(sub: string | undefined, args: ParsedArgs): Promise<number>;