@sequenceholdings/studio-cli 0.1.11 → 0.1.13

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
@@ -97,13 +97,15 @@ You can always add or override environments yourself in
97
97
  `~/.config/lattice/config.toml` (user entries win over discovered ones):
98
98
 
99
99
  ```toml
100
+ # Must come before any [env.*] table — TOML attaches bare keys to the
101
+ # preceding table, so a trailing default_env is silently ignored.
102
+ default_env = "local"
103
+
100
104
  [env.local]
101
105
  url = "http://localhost:5001"
102
106
 
103
107
  [env.my-atlas]
104
108
  url = "https://atlas.example.com"
105
-
106
- default_env = "local"
107
109
  ```
108
110
 
109
111
  Pass `--env <name>` (or `-e <name>`) on commands that talk to the platform.
@@ -121,6 +123,7 @@ the artifact folder's `.artifact-studio/config.json` `defaultEnv` (set by
121
123
  | `seq-studio process lint` | Static checks (graph, return contracts, agent schema, timeouts) |
122
124
  | `seq-studio process plan -e <env>` | Build bundle, diff against currently-active version |
123
125
  | `seq-studio process apply -e <env> [--only <id1,id2>]` | Build → register bundle → promote each process. `--only` promotes just the named process ids (the bundle still contains the whole root — registration is inert) |
126
+ | `seq-studio process apply --repo processes/<name> [--ref <r>] -e <env> [--only <id1,id2>]` | Same as above, but materializes the source from a platform git-service repo. Pinned commit SHA is injected as bundle provenance. Requires `ATLAS_GIT_PAT` (or `AUTH0_M2M_CLIENT_SECRET` for CI) |
124
127
  | `seq-studio process test -e <env>` | CI wrapper: lint + plan, non-zero exit on errors or BREAKING diffs |
125
128
  | `seq-studio process simulate <id>` | In-process walk with stubbed runners (offline) |
126
129
  | `seq-studio process bundle build [-o file.json]` | Build a bundle locally |
@@ -129,6 +132,25 @@ the artifact folder's `.artifact-studio/config.json` `defaultEnv` (set by
129
132
  | `seq-studio process bundle list [-e <env>] [--limit N] [--cursor <hash>]` | List registered bundles (paginated; CLI auto-fetches all pages) |
130
133
  | `seq-studio process bundle publish <hash or bundle.json> [-e <env>]` | Register a local bundle (no promote) |
131
134
 
135
+ ### Remote source for `apply`
136
+
137
+ `apply` can deploy a process from the platform git service instead of a local
138
+ checkout:
139
+
140
+ ```bash
141
+ seq-studio process apply --repo processes/my-process -e staging
142
+ seq-studio process apply --repo processes/my-process --ref v1.2.0 -e production
143
+ ```
144
+
145
+ The source is materialized to a temp dir, dependencies are installed from the
146
+ committed `pnpm-lock.yaml` (must be Chainguard-resolved), process definitions
147
+ are discovered, the bundle is built with the pinned commit as provenance, and the
148
+ temp tree is cleaned up — even on error. Only the `processes` namespace is
149
+ accepted; other namespaces (artifacts, managed-functions) are rejected.
150
+
151
+ Auth: same as `artifact deploy --repo` — `ATLAS_GIT_PAT` for the smart-HTTP
152
+ clone path, or `AUTH0_M2M_CLIENT_SECRET` for CI's JSON materialize path.
153
+
132
154
  ### Process discovery
133
155
 
134
156
  `seq-studio` walks the current working directory for any subfolder
package/dist/bin.js CHANGED
@@ -1,8 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { run } from './main.js';
3
+ import { maybeNotifyUpdate } from './update-check.js';
3
4
  run()
4
- .then((code) => process.exit(code))
5
5
  .catch((error) => {
6
6
  console.error(error instanceof Error ? error.message : error);
7
- process.exit(1);
7
+ return 1;
8
+ })
9
+ // The update notice runs after the command so it never delays real output,
10
+ // and it is best-effort (never throws, never changes the exit code).
11
+ .then(async (code) => {
12
+ await maybeNotifyUpdate();
13
+ process.exit(code);
8
14
  });
package/dist/config.d.ts CHANGED
@@ -38,6 +38,16 @@ export interface ResolvedEnv {
38
38
  name: string;
39
39
  url: string;
40
40
  }
41
+ /**
42
+ * Copy-pasteable fallback when the discovery catalog is unavailable.
43
+ *
44
+ * The `[env.*]` table is safe to append to an existing config.toml. We
45
+ * intentionally omit `default_env` from the snippet — appending it after
46
+ * an existing `[env.*]` table would scope it into that table (TOML bare-key
47
+ * rule) and silently no-op. Callers who want a default should put
48
+ * `default_env = "..."` at the top of the file, before any table.
49
+ */
50
+ export declare function manualEnvConfigHint(): string;
41
51
  /**
42
52
  * Resolve `--env <name>` against the effective config. Falls back to the
43
53
  * config's `defaultEnv` when no name is passed. Throws a clear error when
package/dist/config.js CHANGED
@@ -82,18 +82,40 @@ export async function writeConfig(config) {
82
82
  // smol-toml's stringify can't accept arbitrary nested keys via the
83
83
  // `env.<name>` syntax directly; we build it manually for stable
84
84
  // ordering and clean output.
85
- const lines = [];
85
+ //
86
+ // `default_env` MUST come before any `[env.*]` table. In TOML, a bare
87
+ // key after a table header belongs to that table — putting it last
88
+ // would silently attach it to the final `[env.*]` and `readConfig`
89
+ // (which only reads top-level `default_env`) would ignore it.
90
+ const lines = [`default_env = "${config.defaultEnv}"`, ''];
86
91
  for (const [name, value] of Object.entries(config.envs)) {
87
92
  lines.push(`[env.${name}]`);
88
93
  lines.push(`url = ${stringifyToml({ url: value.url }).trim().replace(/^url = /, '')}`);
89
94
  lines.push('');
90
95
  }
91
- lines.push(`default_env = "${config.defaultEnv}"`);
92
96
  await writeFile(path, lines.join('\n') + '\n', { encoding: 'utf8', mode: 0o600 });
93
97
  }
94
98
  /** Shared hint appended to visibility errors at the anonymous/partner tiers. */
95
99
  const DISCOVERY_HINT = 'If you expect more environments, authenticate (seq-studio login, or set ' +
96
100
  'AUTH0_M2M_CLIENT_SECRET) and run: seq-studio envs refresh';
101
+ /**
102
+ * Copy-pasteable fallback when the discovery catalog is unavailable.
103
+ *
104
+ * The `[env.*]` table is safe to append to an existing config.toml. We
105
+ * intentionally omit `default_env` from the snippet — appending it after
106
+ * an existing `[env.*]` table would scope it into that table (TOML bare-key
107
+ * rule) and silently no-op. Callers who want a default should put
108
+ * `default_env = "..."` at the top of the file, before any table.
109
+ */
110
+ export function manualEnvConfigHint() {
111
+ return (`Or add an environment manually in ${configPath()} (append-safe):\n` +
112
+ `\n` +
113
+ ` [env.my-env]\n` +
114
+ ` url = "https://your-atlas-host.example.com"\n` +
115
+ `\n` +
116
+ `To make it the default, put \`default_env = "my-env"\` at the TOP of ` +
117
+ `that file — before any [env.*] table.`);
118
+ }
97
119
  /**
98
120
  * Resolve `--env <name>` against the effective config. Falls back to the
99
121
  * config's `defaultEnv` when no name is passed. Throws a clear error when
@@ -1,4 +1,5 @@
1
- import { readConfig, configPath } from '../config.js';
1
+ import { tryGetAccessToken } from '../auth.js';
2
+ import { readConfig, configPath, manualEnvConfigHint } from '../config.js';
2
3
  import { bootstrapUrl, fetchCatalog, readCachedCatalog, } from '../env-catalog.js';
3
4
  const ENVS_USAGE = `usage:
4
5
  seq-studio envs list show the environments visible to your identity
@@ -30,6 +31,24 @@ export async function runEnvsCommand(sub, _rest) {
30
31
  return 1;
31
32
  }
32
33
  }
34
+ /**
35
+ * Tier `anonymous` means "no catalog cached", not necessarily "no token".
36
+ * Shared by list + refresh so they don't contradict each other.
37
+ */
38
+ async function printNoCatalogGuidance(log = console.log) {
39
+ const token = await tryGetAccessToken();
40
+ log('');
41
+ if (token) {
42
+ log(`Logged in, but no environment catalog is cached against ${bootstrapUrl()} — ` +
43
+ 'only "local" (plus any config.toml entries) is visible. Run: seq-studio envs refresh');
44
+ }
45
+ else {
46
+ log('Not authenticated — only "local" is visible. Authenticate with ' +
47
+ '`seq-studio login` (or set AUTH0_M2M_CLIENT_SECRET), then run: seq-studio envs refresh');
48
+ }
49
+ log('');
50
+ log(manualEnvConfigHint());
51
+ }
33
52
  async function listCommand() {
34
53
  const [config, catalog] = await Promise.all([readConfig(), readCachedCatalog()]);
35
54
  const discovered = new Set(catalog?.environments.map((env) => env.name) ?? []);
@@ -48,9 +67,7 @@ async function listCommand() {
48
67
  console.log(` ${name.padEnd(width)} ${url} (${source})`);
49
68
  }
50
69
  if (config.tier === 'anonymous') {
51
- console.log('');
52
- console.log('Not authenticated — only "local" is visible. Authenticate with ' +
53
- '`seq-studio login` (or set AUTH0_M2M_CLIENT_SECRET), then run: seq-studio envs refresh');
70
+ await printNoCatalogGuidance();
54
71
  }
55
72
  return 0;
56
73
  }
@@ -61,12 +78,12 @@ async function refreshCommand() {
61
78
  }
62
79
  catch (err) {
63
80
  console.error(`envs refresh: FAIL — ${err instanceof Error ? err.message : String(err)}`);
81
+ console.error('');
82
+ console.error(manualEnvConfigHint());
64
83
  return 1;
65
84
  }
66
85
  if (!catalog) {
67
- console.log(`Not authenticated against ${bootstrapUrl()} — catalog cleared; only ` +
68
- '"local" (plus any config.toml entries) is visible.\n' +
69
- 'Authenticate with `seq-studio login` (or set AUTH0_M2M_CLIENT_SECRET) and retry.');
86
+ await printNoCatalogGuidance();
70
87
  return 0;
71
88
  }
72
89
  console.log(`tier: ${catalog.tier} — ${catalog.environments.length} environment(s) cached.`);
package/dist/login.d.ts CHANGED
@@ -7,6 +7,12 @@ interface LoginOptions {
7
7
  }
8
8
  export declare function openSystemBrowser(authorizationUrl: string): Promise<void>;
9
9
  export declare function loginWithPkce({ fetchImpl, now, openBrowser, port, timeoutMs, }: LoginOptions): Promise<void>;
10
+ /**
11
+ * Best-effort catalog refresh after a successful login. Drop the old cache
12
+ * first so a prior identity's tier never lingers; discovery failures must not
13
+ * fail login. Exported for unit tests.
14
+ */
15
+ export declare function refreshCatalogAfterLogin(): Promise<void>;
10
16
  export declare function login(): Promise<void>;
11
17
  export declare function logout(): Promise<void>;
12
18
  export {};
package/dist/login.js CHANGED
@@ -5,7 +5,8 @@ import { spawn } from 'node:child_process';
5
5
  import { homedir } from 'node:os';
6
6
  import { join } from 'node:path';
7
7
  import { AUTH0_AUDIENCE, AUTH0_CLIENT_ID, AUTH0_DOMAIN, saveTokens, seqapiTokenPath, } from './auth.js';
8
- import { clearCatalog, fetchCatalog } from './env-catalog.js';
8
+ import { manualEnvConfigHint } from './config.js';
9
+ import { bootstrapUrl, clearCatalog, fetchCatalog } from './env-catalog.js';
9
10
  const DEFAULT_REDIRECT_PORT = 5099;
10
11
  const DEFAULT_LOGIN_TIMEOUT_MS = 300_000;
11
12
  function base64Url(input) {
@@ -193,25 +194,32 @@ export async function loginWithPkce({ fetchImpl = fetch, now = Date.now, openBro
193
194
  expires_at: now() / 1_000 + tokens.expiresIn,
194
195
  });
195
196
  }
196
- export async function login() {
197
- await loginWithPkce({});
198
- console.log(`Authenticated. Tokens saved to ${seqapiTokenPath()}.`);
199
- // Refresh the environment catalog for the new identity immediately, so a
200
- // previously cached catalog from another identity (or tier) never lingers
201
- // past a login. Drop the old cache first — if the refresh below fails, the
202
- // prior identity's catalog must not survive the login either.
203
- // Best-effort: discovery being unreachable must not fail login.
197
+ /**
198
+ * Best-effort catalog refresh after a successful login. Drop the old cache
199
+ * first so a prior identity's tier never lingers; discovery failures must not
200
+ * fail login. Exported for unit tests.
201
+ */
202
+ export async function refreshCatalogAfterLogin() {
204
203
  await clearCatalog();
205
204
  try {
206
205
  const catalog = await fetchCatalog();
207
206
  if (catalog) {
208
207
  console.log(`Environment catalog refreshed (tier: ${catalog.tier}, ` +
209
208
  `${catalog.environments.length} environment(s)).`);
209
+ return;
210
210
  }
211
211
  }
212
212
  catch {
213
- console.log('Could not refresh the environment catalog run: seq-studio envs refresh');
213
+ // Network / non-auth discovery errors fall through to the same hint.
214
214
  }
215
+ console.log(`Could not load the environment catalog from ${bootstrapUrl()} — ` +
216
+ 'run: seq-studio envs refresh');
217
+ console.log(manualEnvConfigHint());
218
+ }
219
+ export async function login() {
220
+ await loginWithPkce({});
221
+ console.log(`Authenticated. Tokens saved to ${seqapiTokenPath()}.`);
222
+ await refreshCatalogAfterLogin();
215
223
  }
216
224
  /**
217
225
  * Pre-unification artifact-studio token file. `seq-studio artifact` commands
package/dist/main.js CHANGED
@@ -30,6 +30,7 @@ const TOP_LEVEL_USAGE = `usage:
30
30
  seq-studio login authenticate in the browser
31
31
  seq-studio logout remove cached user tokens
32
32
  seq-studio doctor [-e <env>] diagnose config, auth, and writer gate
33
+ seq-studio version print the installed version
33
34
  seq-studio help show this message
34
35
 
35
36
  Authenticate with: seq-studio login
@@ -41,6 +42,7 @@ const PROCESS_USAGE = `usage:
41
42
  seq-studio process lint
42
43
  seq-studio process plan [-e <env>]
43
44
  seq-studio process apply [-e <env>] [--only <id1,id2,...>]
45
+ seq-studio process apply --repo processes/<name> [--ref <r>] -e <env> [--only <id1,id2,...>]
44
46
  seq-studio process promote <processId> --version <v> [-e <env>]
45
47
  seq-studio process pull <processId> [--version <v>] [-e <env>] [--out <dir>] [--no-children]
46
48
  seq-studio process test [-e <env>] [--offline]
@@ -92,6 +94,13 @@ export async function run(argv = process.argv.slice(2)) {
92
94
  case 'login':
93
95
  case 'logout':
94
96
  return runSessionCommand({ argument: sub, command: namespace });
97
+ case 'version':
98
+ case '--version':
99
+ case '-v': {
100
+ const { currentVersion } = await import('./update-check.js');
101
+ console.log(currentVersion());
102
+ return 0;
103
+ }
95
104
  case 'doctor':
96
105
  return doctorCommand(parseArgs([sub, ...rest].filter(Boolean)));
97
106
  default:
@@ -5,4 +5,13 @@
5
5
  * `artifact`): resolve `--env` from `~/.config/lattice/config.toml`, set
6
6
  * SEQUENCE_ORM_BASE_URL, share the seqapi token, forward argv.
7
7
  */
8
+ /**
9
+ * Classify an ERR_MODULE_NOT_FOUND from importing `@sequenceholdings/orm/cli`:
10
+ * is the orm package itself absent, or is it present but a dependency (e.g.
11
+ * drizzle-orm) failed to resolve? Match the MISSING specifier (the quoted name
12
+ * after "Cannot find package/module"), never the whole message: a transitive
13
+ * failure's text includes the importer path (`.../node_modules/@sequenceholdings/orm/...`),
14
+ * so a substring check would misread a missing dep as the orm package itself.
15
+ */
16
+ export declare function isOrmPackageMissing(message: string): boolean;
8
17
  export declare function runOrmCommand(sub: string | undefined, rest: string[]): Promise<number>;
@@ -19,6 +19,18 @@ const ORM_USAGE = `usage:
19
19
  discovered after you authenticate).
20
20
  Authenticate with: seq-studio login
21
21
  `;
22
+ /**
23
+ * Classify an ERR_MODULE_NOT_FOUND from importing `@sequenceholdings/orm/cli`:
24
+ * is the orm package itself absent, or is it present but a dependency (e.g.
25
+ * drizzle-orm) failed to resolve? Match the MISSING specifier (the quoted name
26
+ * after "Cannot find package/module"), never the whole message: a transitive
27
+ * failure's text includes the importer path (`.../node_modules/@sequenceholdings/orm/...`),
28
+ * so a substring check would misread a missing dep as the orm package itself.
29
+ */
30
+ export function isOrmPackageMissing(message) {
31
+ const specifier = message.match(/Cannot find (?:package|module) '([^']+)'/)?.[1];
32
+ return specifier === '@sequenceholdings/orm' || (specifier?.startsWith('@sequenceholdings/orm/') ?? false);
33
+ }
22
34
  export async function runOrmCommand(sub, rest) {
23
35
  if (!sub || sub === 'help' || sub === '--help' || sub === '-h') {
24
36
  console.log(ORM_USAGE);
@@ -42,8 +54,15 @@ export async function runOrmCommand(sub, rest) {
42
54
  }
43
55
  catch (err) {
44
56
  if (err.code === 'ERR_MODULE_NOT_FOUND') {
45
- console.error('seq-studio orm requires @sequenceholdings/orm, which is not included ' +
46
- 'in this installation. It is available to Sequence-internal setups only.');
57
+ const message = err instanceof Error ? err.message : String(err);
58
+ if (isOrmPackageMissing(message)) {
59
+ console.error('seq-studio orm needs the @sequenceholdings/orm package. Install it alongside the CLI:\n' +
60
+ ' npm install @sequenceholdings/orm');
61
+ }
62
+ else {
63
+ console.error('@sequenceholdings/orm is installed but a dependency failed to load. Reinstall dependencies, then retry.\n' +
64
+ ` ${message}`);
65
+ }
47
66
  return 1;
48
67
  }
49
68
  throw err;
@@ -8,7 +8,14 @@ export type ResolveProcessPin = (processId: string, version?: string) => Promise
8
8
  version: string;
9
9
  bundleHash: string;
10
10
  } | null>;
11
- export declare function buildBundleFromProcesses(defs: readonly LoadedProcess[], options?: {
11
+ export interface BuildBundleOptions {
12
12
  resolveProcessPin?: ResolveProcessPin;
13
- }): Promise<LatticeBundle>;
13
+ /** Override git provenance instead of reading from the local working tree. */
14
+ provenance?: {
15
+ gitCommit: string | null;
16
+ gitBranch: string | null;
17
+ gitDirty: boolean | null;
18
+ };
19
+ }
20
+ export declare function buildBundleFromProcesses(defs: readonly LoadedProcess[], options?: BuildBundleOptions): Promise<LatticeBundle>;
14
21
  export declare function summarizeBundle(bundle: LatticeBundle): LatticeBundleSummary;
@@ -20,9 +20,12 @@ export async function buildBundleFromProcesses(defs, options) {
20
20
  return options.resolveProcessPin(processId, version);
21
21
  });
22
22
  }
23
+ const metadata = options?.provenance
24
+ ? collectGitMetadataWithOverrides(options.provenance)
25
+ : collectGitMetadata();
23
26
  // Manifest, hash, version, and co-located subprocess pins are all derived in
24
27
  // the SDK so a CLI-built bundle is byte-identical to a server-applied one.
25
- return finalizeBundleIdentity({ processes, metadata: collectGitMetadata() });
28
+ return finalizeBundleIdentity({ processes, metadata });
26
29
  }
27
30
  async function resolveSubprocessPinsInProcesses(processes, resolve) {
28
31
  const pins = [];
@@ -266,6 +269,11 @@ function serializeNodeMetadata(node) {
266
269
  on_branch_error: parallel.on_branch_error,
267
270
  join_source: toSource(parallel.join),
268
271
  };
272
+ // Serialized only when non-default ('eager') so existing barrier
273
+ // bundles keep their hashes; a missing field reads as 'barrier'.
274
+ if (parallel.join_mode === 'eager') {
275
+ pmeta.join_mode = 'eager';
276
+ }
269
277
  if (parallel.max_concurrency !== undefined) {
270
278
  pmeta.max_concurrency = parallel.max_concurrency;
271
279
  }
@@ -348,6 +356,15 @@ function collectGitMetadata() {
348
356
  git_dirty: get('git status --porcelain') !== '',
349
357
  };
350
358
  }
359
+ function collectGitMetadataWithOverrides(provenance) {
360
+ return {
361
+ created_at: new Date().toISOString(),
362
+ created_by: process.env['USER'] ?? null,
363
+ git_commit: provenance.gitCommit,
364
+ git_branch: provenance.gitBranch,
365
+ git_dirty: provenance.gitDirty ?? false,
366
+ };
367
+ }
351
368
  export function summarizeBundle(bundle) {
352
369
  return {
353
370
  name: bundle.manifest.bundle.name,
@@ -171,6 +171,8 @@ function emitNode(node, ctx) {
171
171
  par.supervisor_retry !== undefined ? ` supervisor_retry: ${pretty(par.supervisor_retry, 1)},` : null,
172
172
  bindings(m.bindings),
173
173
  ` on_branch_error: ${lit(par.on_branch_error)},`,
174
+ // Only 'eager' is ever serialized (barrier is the omitted default).
175
+ par.join_mode === 'eager' ? ` join_mode: ${lit('eager')},` : null,
174
176
  ` join: ${par.join_source},`,
175
177
  mapper('input', m.input_mapper_source),
176
178
  tail,
@@ -9,6 +9,25 @@ export interface ParsedArgs {
9
9
  flags: Record<string, string | true>;
10
10
  }
11
11
  export declare function parseArgs(rest: string[]): ParsedArgs;
12
+ export type ProcessSourceSpec = {
13
+ kind: 'local';
14
+ } | {
15
+ kind: 'git-service';
16
+ namespace: string;
17
+ name: string;
18
+ ref?: string;
19
+ };
20
+ /**
21
+ * Parse the process-apply source flags. `--repo processes/<name>` materializes
22
+ * from the platform git service; absent means local (cwd / LATTICE_PROCESSES_ROOT).
23
+ * `--git-url` is passed through to the underlying `resolveArtifactSource` when
24
+ * the repo flag is a full ns/name pair (future); today only git-service is
25
+ * supported.
26
+ *
27
+ * Process repos live exclusively in the `processes` namespace — other namespaces
28
+ * (artifacts, managed-functions) are rejected to avoid cross-kind accidents.
29
+ */
30
+ export declare function parseProcessSourceSpec(flags: Record<string, string | true>): ProcessSourceSpec;
12
31
  export declare function initCommand(args: ParsedArgs): Promise<number>;
13
32
  export declare function lintCommand(args: ParsedArgs): Promise<number>;
14
33
  export declare function planCommand(args: ParsedArgs): Promise<number>;
@@ -21,6 +21,8 @@ import { lintProcesses, formatLintResult } from './lint.js';
21
21
  import { diffBundleAgainstActive, formatPlanDiff, } from './plan-diff.js';
22
22
  import { simulateProcess, formatSimulateResult } from './simulate.js';
23
23
  import { buildAgentSchemaLoader } from './agent-loader.js';
24
+ import { resolveArtifactSource, } from '@sequenceholdings/artifact-studio/source-resolver';
25
+ import { prepareProcessBuildRoot } from './repo-install.js';
24
26
  export function parseArgs(rest) {
25
27
  const positional = [];
26
28
  const flags = {};
@@ -106,6 +108,39 @@ async function getEnvAndToken(args) {
106
108
  }
107
109
  return { env, token };
108
110
  }
111
+ /**
112
+ * Parse the process-apply source flags. `--repo processes/<name>` materializes
113
+ * from the platform git service; absent means local (cwd / LATTICE_PROCESSES_ROOT).
114
+ * `--git-url` is passed through to the underlying `resolveArtifactSource` when
115
+ * the repo flag is a full ns/name pair (future); today only git-service is
116
+ * supported.
117
+ *
118
+ * Process repos live exclusively in the `processes` namespace — other namespaces
119
+ * (artifacts, managed-functions) are rejected to avoid cross-kind accidents.
120
+ */
121
+ export function parseProcessSourceSpec(flags) {
122
+ const repo = typeof flags.repo === 'string' ? flags.repo : undefined;
123
+ const ref = typeof flags.ref === 'string' ? flags.ref : undefined;
124
+ if (flags.repo === true)
125
+ throw new Error('--repo requires a value: --repo processes/<name>');
126
+ if (flags.ref === true)
127
+ throw new Error('--ref requires a value.');
128
+ if (ref && !repo)
129
+ throw new Error('--ref only applies together with --repo.');
130
+ if (ref && ref.startsWith('-'))
131
+ throw new Error(`--ref must not start with "-" (got "${ref}").`);
132
+ if (!repo)
133
+ return { kind: 'local' };
134
+ const parts = repo.split('/');
135
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
136
+ throw new Error(`--repo must be "processes/<name>" (got "${repo}").`);
137
+ }
138
+ if (parts[0] !== 'processes') {
139
+ throw new Error(`Process deploys require the "processes" namespace — got "${parts[0]}". ` +
140
+ `Use --repo processes/<name>.`);
141
+ }
142
+ return { kind: 'git-service', namespace: parts[0], name: parts[1], ref };
143
+ }
109
144
  // ---------------------------------------------------------------------------
110
145
  // init
111
146
  // ---------------------------------------------------------------------------
@@ -199,18 +234,18 @@ async function tryBuildAgentLoader(args) {
199
234
  // ---------------------------------------------------------------------------
200
235
  // plan
201
236
  // ---------------------------------------------------------------------------
202
- async function buildBundleForPublish(args, defs) {
237
+ async function buildBundleForPublish(args, defs, provenance) {
203
238
  const hasSubprocess = defs.some((d) => d.process.nodes.some((n) => n.kind === 'subprocess'));
204
239
  try {
205
240
  const { env, token } = await getEnvAndToken(args);
206
241
  const resolveProcessPin = buildResolveProcessPinFromEnv(env, token);
207
- return await buildBundleFromProcesses(defs, { resolveProcessPin });
242
+ return await buildBundleFromProcesses(defs, { resolveProcessPin, provenance });
208
243
  }
209
244
  catch (err) {
210
245
  if (hasSubprocess) {
211
246
  throw new Error('subprocess nodes require -e <env> and `seq-studio login` to resolve child process versions', { cause: err });
212
247
  }
213
- return await buildBundleFromProcesses(defs);
248
+ return await buildBundleFromProcesses(defs, { provenance });
214
249
  }
215
250
  }
216
251
  export async function planCommand(args) {
@@ -334,8 +369,36 @@ export function resolveOnlyIds({ only, knownIds, }) {
334
369
  return { ids };
335
370
  }
336
371
  export async function applyCommand(args) {
372
+ const spec = parseProcessSourceSpec(args.flags);
373
+ if (spec.kind === 'git-service') {
374
+ return applyFromRepo(args, spec);
375
+ }
376
+ return applyLocal(args);
377
+ }
378
+ async function applyLocal(args) {
337
379
  const { env, token } = await getEnvAndToken(args);
338
380
  const defs = await loadProcessDefinitions();
381
+ return registerAndPromote({ args, env, token, defs });
382
+ }
383
+ async function applyFromRepo(args, spec) {
384
+ const { env, token } = await getEnvAndToken(args);
385
+ const source = await resolveArtifactSource({ kind: 'git-service', namespace: spec.namespace, name: spec.name, ref: spec.ref }, { baseUrl: env.url, token });
386
+ try {
387
+ await prepareProcessBuildRoot(source.dir);
388
+ const defs = await loadProcessDefinitions(source.dir);
389
+ return await registerAndPromote({
390
+ args,
391
+ env,
392
+ token,
393
+ defs,
394
+ provenance: source.provenance,
395
+ });
396
+ }
397
+ finally {
398
+ await source.cleanup();
399
+ }
400
+ }
401
+ async function registerAndPromote({ args, env, token, defs, provenance, }) {
339
402
  const onlyResult = resolveOnlyIds({
340
403
  only: args.flags.only,
341
404
  knownIds: new Set(defs.map((d) => d.process.id)),
@@ -357,7 +420,7 @@ export async function applyCommand(args) {
357
420
  console.error('\napply FAILED — lint rejected the bundle');
358
421
  return 1;
359
422
  }
360
- const bundle = await buildBundleForPublish(args, defs);
423
+ const bundle = await buildBundleForPublish(args, defs, provenance);
361
424
  console.log(`[seq-studio] built bundle ${bundle.bundle_hash.slice(0, 12)} ` +
362
425
  `(${bundle.processes.length} process(es))`);
363
426
  // We send only the bundle. `created_by` (and `promoted_by` for the
@@ -668,7 +731,7 @@ export async function doctorCommand(args) {
668
731
  lines.push(`config: ok — default_env=${config.defaultEnv}, envs=${Object.keys(config.envs).join(',')}`);
669
732
  lines.push(`tier: ${config.tier}` +
670
733
  (config.tier === 'anonymous'
671
- ? ' — authenticate and run `seq-studio envs refresh` to discover more environments'
734
+ ? ' — no environment catalog cached; run `seq-studio envs refresh` to discover more environments'
672
735
  : ''));
673
736
  }
674
737
  const requested = typeof args.flags.env === 'string' ? args.flags.env : undefined;
@@ -28,5 +28,8 @@ export declare function findProcessRoot(): Promise<string>;
28
28
  * Load every `<dir>/process.ts` under the configured root. Each module
29
29
  * must `export default defineProcess(...)`. Process ids must be unique
30
30
  * across the set — duplicates throw.
31
+ *
32
+ * @param explicitRoot When supplied, overrides LATTICE_PROCESSES_ROOT and cwd.
33
+ * Used by `--repo` deploys to point discovery at the materialized temp tree.
31
34
  */
32
- export declare function loadProcessDefinitions(): Promise<readonly LoadedProcess[]>;
35
+ export declare function loadProcessDefinitions(explicitRoot?: string): Promise<readonly LoadedProcess[]>;
@@ -55,9 +55,12 @@ export async function findProcessRoot() {
55
55
  * Load every `<dir>/process.ts` under the configured root. Each module
56
56
  * must `export default defineProcess(...)`. Process ids must be unique
57
57
  * across the set — duplicates throw.
58
+ *
59
+ * @param explicitRoot When supplied, overrides LATTICE_PROCESSES_ROOT and cwd.
60
+ * Used by `--repo` deploys to point discovery at the materialized temp tree.
58
61
  */
59
- export async function loadProcessDefinitions() {
60
- const root = await findProcessRoot();
62
+ export async function loadProcessDefinitions(explicitRoot) {
63
+ const root = explicitRoot ?? (await findProcessRoot());
61
64
  const entries = await readdir(root);
62
65
  const found = [];
63
66
  const seenIds = new Set();
@@ -402,17 +402,66 @@ async function lintAgent(processId, node, errors, warnings, loadAgentSchemaEdgeI
402
402
  });
403
403
  }
404
404
  }
405
+ /**
406
+ * Matches an undecided-sentinel return in a serialized join source: the
407
+ * literal `{ undecided: true }` (the correct authored form — reducers run in
408
+ * a vm with no module imports in scope) or a `JOIN_UNDECIDED` reference
409
+ * (recognized so the eager no-sentinel warning doesn't double-fire on top of
410
+ * the closure-scope warning that already flags the out-of-scope import).
411
+ */
412
+ const UNDECIDED_SENTINEL_RE = /\bundecided\s*:\s*true\b|\bJOIN_UNDECIDED\b/;
405
413
  /**
406
414
  * Lint a parallel node's `join` reducer the way we lint automation fns:
407
415
  * scan the serialized source for literal `edge_id: '…'` returns and confirm
408
416
  * each is in the parallel node's own outgoing_edges. The reducer chooses the
409
417
  * parallel node's exit edge, so a typo there is a routing bug the orchestrator
410
418
  * would only surface at run time.
419
+ *
420
+ * join_mode checks: a BARRIER join returning the `{undecided: true}` sentinel
421
+ * is an authoring error (the runner fails the block at runtime), and an EAGER
422
+ * join that never returns the sentinel decides on the first settlement —
423
+ * legal but suspicious, so it warns. No static engine check is possible for
424
+ * eager blocks: the execution engine is chosen per RUN (`{"engine": ...}` +
425
+ * environment rollout policy), not per process — the Trigger-only limitation
426
+ * is enforced at runtime (an eager block on a Temporal run fails with a clear
427
+ * unsupported error at branch resolution).
411
428
  */
412
429
  function lintParallelJoin(processId, node, errors, warnings) {
413
430
  const join = node.join;
414
431
  const source = typeof join === 'function' ? join.toString() : '';
415
432
  const outgoingIds = new Set(node.outgoing_edges.map((e) => e.id));
433
+ const joinMode = node.join_mode ?? 'barrier';
434
+ const returnsUndecided = UNDECIDED_SENTINEL_RE.test(source);
435
+ if (joinMode === 'eager') {
436
+ if (typeof join !== 'function') {
437
+ // defineParallelNode already rejects this; belt-and-suspenders for
438
+ // hand-built definitions that bypassed the SDK constructor.
439
+ errors.push({
440
+ process_id: processId,
441
+ node_id: node.id,
442
+ message: `join_mode 'eager' requires an explicit join reducer`,
443
+ });
444
+ }
445
+ if (!returnsUndecided) {
446
+ warnings.push({
447
+ process_id: processId,
448
+ node_id: node.id,
449
+ message: `join_mode is 'eager' but the join source never returns the ` +
450
+ `{undecided: true} sentinel — the block will decide on the FIRST ` +
451
+ `branch settlement; confirm that is intended`,
452
+ });
453
+ }
454
+ }
455
+ else if (returnsUndecided) {
456
+ errors.push({
457
+ process_id: processId,
458
+ node_id: node.id,
459
+ message: `parallel join returns the {undecided: true} sentinel but join_mode ` +
460
+ `is 'barrier' (the default) — the sentinel is only meaningful with ` +
461
+ `join_mode: 'eager'; at runtime the block FAILS when a barrier join ` +
462
+ `returns it`,
463
+ });
464
+ }
416
465
  const literals = new Set();
417
466
  for (const match of source.matchAll(EDGE_ID_LITERAL_RE)) {
418
467
  if (match[1])
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Remote-install helper for `process apply --repo`. Installs dependencies
3
+ * in a materialized process repo so `tsx` can import `process.ts` modules.
4
+ *
5
+ * Re-uses the artifact-studio trusted-install pipeline:
6
+ * 1. Require `package.json` and `pnpm-lock.yaml` (no floating installs)
7
+ * 2. Strip install-control files (.npmrc, pnpm-workspace.yaml, pnpm hooks)
8
+ * 3. Full trust validation: registry-only specs, no patches, no symlinks,
9
+ * Chainguard lockfile with repo/commit/directory/tarball checks
10
+ * 4. Run pinned pnpm with frozen lockfile, ignore-scripts, prod-only
11
+ */
12
+ /**
13
+ * Install dependencies in a materialized remote process repo. Process repos
14
+ * deployed from git-service must carry a `package.json` (process.ts files
15
+ * import `@sequenceholdings/lattice/define` and the module won't resolve
16
+ * without node_modules) and a Chainguard-resolved `pnpm-lock.yaml`.
17
+ *
18
+ * Throws when either file is missing or when the tree fails the full
19
+ * trusted-install validation.
20
+ */
21
+ export declare function prepareProcessBuildRoot(dir: string): Promise<void>;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Remote-install helper for `process apply --repo`. Installs dependencies
3
+ * in a materialized process repo so `tsx` can import `process.ts` modules.
4
+ *
5
+ * Re-uses the artifact-studio trusted-install pipeline:
6
+ * 1. Require `package.json` and `pnpm-lock.yaml` (no floating installs)
7
+ * 2. Strip install-control files (.npmrc, pnpm-workspace.yaml, pnpm hooks)
8
+ * 3. Full trust validation: registry-only specs, no patches, no symlinks,
9
+ * Chainguard lockfile with repo/commit/directory/tarball checks
10
+ * 4. Run pinned pnpm with frozen lockfile, ignore-scripts, prod-only
11
+ */
12
+ import { execFileSync } from 'node:child_process';
13
+ import { existsSync } from 'node:fs';
14
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
15
+ import { homedir, tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { ARTIFACT_BUILD_PNPM, buildControlledArtifactNpmrc, } from '@sequenceholdings/artifact-studio/prepare-build';
18
+ import { stripInstallControlFiles } from '@sequenceholdings/artifact-studio/sanitize-remote-tree';
19
+ import { ArtifactInstallTrustError, assertTrustedArtifactInstallTree, } from '@sequenceholdings/artifact-studio/trusted-install';
20
+ class ProcessInstallError extends Error {
21
+ name = 'ProcessInstallError';
22
+ }
23
+ /**
24
+ * Install dependencies in a materialized remote process repo. Process repos
25
+ * deployed from git-service must carry a `package.json` (process.ts files
26
+ * import `@sequenceholdings/lattice/define` and the module won't resolve
27
+ * without node_modules) and a Chainguard-resolved `pnpm-lock.yaml`.
28
+ *
29
+ * Throws when either file is missing or when the tree fails the full
30
+ * trusted-install validation.
31
+ */
32
+ export async function prepareProcessBuildRoot(dir) {
33
+ if (!existsSync(join(dir, 'package.json'))) {
34
+ throw new ProcessInstallError('Remote process repos must include a package.json with @sequenceholdings/lattice as a dependency.');
35
+ }
36
+ if (!existsSync(join(dir, 'pnpm-lock.yaml'))) {
37
+ throw new ProcessInstallError('Remote process deploy requires pnpm-lock.yaml — run `pnpm install` locally and commit the lockfile.');
38
+ }
39
+ const stripped = stripInstallControlFiles(dir);
40
+ if (stripped.length > 0) {
41
+ console.warn(`[seq-studio] stripped install-control files from remote tree: ${stripped.join(', ')}`);
42
+ }
43
+ try {
44
+ assertTrustedArtifactInstallTree(dir);
45
+ }
46
+ catch (error) {
47
+ if (error instanceof ArtifactInstallTrustError) {
48
+ throw new ProcessInstallError(`Remote process repo is not trusted for install: ${error.message}`);
49
+ }
50
+ throw error;
51
+ }
52
+ await rm(join(dir, 'node_modules'), { recursive: true, force: true });
53
+ const configDir = await mkdtemp(join(tmpdir(), 'process-pnpm-'));
54
+ const controlledNpmrc = join(configDir, '.npmrc');
55
+ const userNpmrcPath = process.env.npm_config_userconfig ?? join(homedir(), '.npmrc');
56
+ console.log(`[seq-studio] installing process dependencies (frozen lockfile, prod only) via ${ARTIFACT_BUILD_PNPM}…`);
57
+ try {
58
+ await writeFile(controlledNpmrc, buildControlledArtifactNpmrc(userNpmrcPath), { mode: 0o600 });
59
+ const spawnOpts = {
60
+ cwd: configDir,
61
+ stdio: 'inherit',
62
+ env: { ...process.env, npm_config_userconfig: controlledNpmrc },
63
+ };
64
+ execFileSync('npx', [
65
+ '--yes',
66
+ ARTIFACT_BUILD_PNPM,
67
+ '--dir',
68
+ dir,
69
+ 'install',
70
+ '--frozen-lockfile',
71
+ '--ignore-scripts',
72
+ '--prod',
73
+ ], spawnOpts);
74
+ }
75
+ catch (error) {
76
+ const detail = error instanceof Error ? error.message : String(error);
77
+ throw new ProcessInstallError(`process dependency install failed (${ARTIFACT_BUILD_PNPM}). ` +
78
+ `Run \`pnpm install\` locally and commit pnpm-lock.yaml. (${detail})`);
79
+ }
80
+ finally {
81
+ await rm(configDir, { recursive: true, force: true });
82
+ }
83
+ }
@@ -10,7 +10,7 @@
10
10
  * - Stubs automation runner identically — native handlers live in the
11
11
  * Atlas image and can't execute here
12
12
  */
13
- import { DEFAULT_MAX_FANOUT_WIDTH, } from '@sequenceholdings/lattice/define';
13
+ import { DEFAULT_MAX_FANOUT_WIDTH, isJoinUndecided, } from '@sequenceholdings/lattice/define';
14
14
  import { extractOutputState, mergeRunState } from '@sequenceholdings/lattice';
15
15
  const DEFAULT_MAX_ITERATIONS = 1000;
16
16
  const DEFAULT_MAX_REVISITS = 100;
@@ -246,7 +246,20 @@ async function simulateParallel(args, node) {
246
246
  item: br.item,
247
247
  });
248
248
  }
249
+ // The simulator settles every branch before joining, so an EAGER join sees
250
+ // the all-settled case (its incremental undecided passes aren't modelled —
251
+ // simulate verifies topology/routing, not settlement timing). In that case
252
+ // the eager contract requires a decision; surface a sentinel return as the
253
+ // same clear error the runtime raises instead of a confusing edge failure.
249
254
  const joinOutput = node.join(results, args.ctx);
255
+ if (isJoinUndecided(joinOutput)) {
256
+ throw new Error(node.join_mode === 'eager'
257
+ ? `parallel node "${node.id}": eager join returned {undecided: true} with ` +
258
+ `every branch settled — an eager join must decide once nothing is pending`
259
+ : `parallel node "${node.id}": join returned the {undecided: true} sentinel ` +
260
+ `but join_mode is 'barrier' — the sentinel is only meaningful with ` +
261
+ `join_mode: 'eager'`);
262
+ }
250
263
  return foldBranchState(joinOutput, results);
251
264
  }
252
265
  /**
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Detached background entry spawned by maybeNotifyUpdate() when the update
3
+ * cache is stale. Polls the npm registry once and persists the result so
4
+ * the *next* command can print the notice without any foreground network.
5
+ */
6
+ import { refreshUpdateCache } from './update-check.js';
7
+ refreshUpdateCache().catch(() => {
8
+ // Best-effort: nothing to report to — the parent process is long gone.
9
+ });
@@ -0,0 +1,27 @@
1
+ /** The version of this installed package (dist/../package.json). */
2
+ export declare function currentVersion(): string;
3
+ export declare function updateCheckCachePath(): string;
4
+ /** Exclusive-create marker that makes "who spawns the refresh?" atomic. */
5
+ export declare function updateCheckClaimPath(): string;
6
+ /**
7
+ * True when `candidate` is a strictly newer semver than `current`.
8
+ * Prerelease suffixes are ignored — the published channel is plain
9
+ * major.minor.patch and this only drives a notice, not resolution.
10
+ */
11
+ export declare function isNewerVersion(candidate: string, current: string): boolean;
12
+ /**
13
+ * Poll the registry and persist the result. Runs in the detached background
14
+ * process (see update-check-refresh.ts) — never on a command's exit path.
15
+ * Failed fetches still record `lastCheckedAt` (keeping any previously known
16
+ * version) so attempts are throttled to once per interval either way. The
17
+ * cache file is written *only* here, so a foreground path can never clobber
18
+ * a fresher result. Releases the spawn claim when done.
19
+ */
20
+ export declare function refreshUpdateCache(): Promise<void>;
21
+ /**
22
+ * Print a strong upgrade recommendation to stderr when the cached registry
23
+ * state says a newer version is published, and kick off a detached refresh
24
+ * when the cache is stale. Reads only local state — adds no network latency
25
+ * to the command. Never throws.
26
+ */
27
+ export declare function maybeNotifyUpdate(): Promise<void>;
@@ -0,0 +1,200 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { readFileSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ /**
8
+ * Update notice: tell interactive users when a newer version of the CLI is
9
+ * on npm, and strongly recommend upgrading.
10
+ *
11
+ * Zero foreground latency is the design constraint. The command path only
12
+ * ever *reads* the on-disk cache (~/.config/lattice/update-check.json) and
13
+ * prints; the registry poll runs in a detached background process spawned
14
+ * at most once per CHECK_INTERVAL_MS — including after failures, which
15
+ * record their attempt time so a downed registry doesn't retry on every
16
+ * command. An exclusive claim file elects a single refresher even across
17
+ * concurrent CLI invocations. The notice is therefore at most one
18
+ * invocation stale, which is the standard update-notifier trade-off.
19
+ *
20
+ * The notice goes to stderr and only when stderr is a TTY, so scripted /
21
+ * piped / CI invocations never see it. Opt out entirely with
22
+ * SEQ_STUDIO_NO_UPDATE_CHECK=1. Everything is best-effort: failures are
23
+ * silent and never affect the command's exit code.
24
+ */
25
+ const PACKAGE_NAME = '@sequenceholdings/studio-cli';
26
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
27
+ const FETCH_TIMEOUT_MS = 5000;
28
+ /** A claim older than this belongs to a refresher that died — take it over. */
29
+ const CLAIM_TTL_MS = 10 * 60 * 1000;
30
+ /** The version of this installed package (dist/../package.json). */
31
+ export function currentVersion() {
32
+ const packageJsonPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
33
+ const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
34
+ const version = typeof parsed === 'object' && parsed !== null ? Reflect.get(parsed, 'version') : null;
35
+ return typeof version === 'string' ? version : '0.0.0';
36
+ }
37
+ export function updateCheckCachePath() {
38
+ return join(homedir(), '.config', 'lattice', 'update-check.json');
39
+ }
40
+ /** Exclusive-create marker that makes "who spawns the refresh?" atomic. */
41
+ export function updateCheckClaimPath() {
42
+ return updateCheckCachePath() + '.claim';
43
+ }
44
+ /**
45
+ * True when `candidate` is a strictly newer semver than `current`.
46
+ * Prerelease suffixes are ignored — the published channel is plain
47
+ * major.minor.patch and this only drives a notice, not resolution.
48
+ */
49
+ export function isNewerVersion(candidate, current) {
50
+ const parse = (v) => v
51
+ .replace(/^v/, '')
52
+ .split('-')[0]
53
+ .split('.')
54
+ .map((part) => Number.parseInt(part, 10) || 0);
55
+ const [a, b] = [parse(candidate), parse(current)];
56
+ for (let i = 0; i < 3; i++) {
57
+ const diff = (a[i] ?? 0) - (b[i] ?? 0);
58
+ if (diff !== 0)
59
+ return diff > 0;
60
+ }
61
+ return false;
62
+ }
63
+ async function readCache() {
64
+ try {
65
+ const parsed = JSON.parse(await readFile(updateCheckCachePath(), 'utf8'));
66
+ if (typeof parsed !== 'object' || parsed === null)
67
+ return null;
68
+ const candidate = parsed;
69
+ if (typeof candidate.lastCheckedAt !== 'number')
70
+ return null;
71
+ if (typeof candidate.latestVersion !== 'string' && candidate.latestVersion !== null) {
72
+ return null;
73
+ }
74
+ return { lastCheckedAt: candidate.lastCheckedAt, latestVersion: candidate.latestVersion };
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
80
+ async function writeCache(cache) {
81
+ const path = updateCheckCachePath();
82
+ await mkdir(dirname(path), { recursive: true });
83
+ await writeFile(path, JSON.stringify(cache, null, 2) + '\n', 'utf8');
84
+ }
85
+ async function fetchLatestVersion() {
86
+ const controller = new AbortController();
87
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
88
+ try {
89
+ const response = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, { signal: controller.signal });
90
+ if (!response.ok)
91
+ return null;
92
+ const body = (await response.json());
93
+ return typeof body.version === 'string' ? body.version : null;
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ finally {
99
+ clearTimeout(timer);
100
+ }
101
+ }
102
+ /**
103
+ * Poll the registry and persist the result. Runs in the detached background
104
+ * process (see update-check-refresh.ts) — never on a command's exit path.
105
+ * Failed fetches still record `lastCheckedAt` (keeping any previously known
106
+ * version) so attempts are throttled to once per interval either way. The
107
+ * cache file is written *only* here, so a foreground path can never clobber
108
+ * a fresher result. Releases the spawn claim when done.
109
+ */
110
+ export async function refreshUpdateCache() {
111
+ try {
112
+ const previous = await readCache();
113
+ const fetched = await fetchLatestVersion();
114
+ await writeCache({
115
+ lastCheckedAt: Date.now(),
116
+ latestVersion: fetched ?? previous?.latestVersion ?? null,
117
+ });
118
+ }
119
+ finally {
120
+ await rm(updateCheckClaimPath(), { force: true }).catch(() => { });
121
+ }
122
+ }
123
+ /**
124
+ * Atomically claim the right to spawn a refresh. The `wx` flag makes the
125
+ * create exclusive at the filesystem level, so concurrent CLI processes
126
+ * racing past a stale cache elect exactly one refresher. A claim whose
127
+ * mtime exceeds CLAIM_TTL_MS is from a refresher that died before releasing
128
+ * it — take it over so refreshes can't wedge forever.
129
+ */
130
+ async function tryClaimRefresh() {
131
+ const path = updateCheckClaimPath();
132
+ try {
133
+ await mkdir(dirname(path), { recursive: true });
134
+ await writeFile(path, String(process.pid), { flag: 'wx' });
135
+ return true;
136
+ }
137
+ catch {
138
+ try {
139
+ const { mtimeMs } = await stat(path);
140
+ if (Date.now() - mtimeMs < CLAIM_TTL_MS)
141
+ return false;
142
+ await rm(path, { force: true });
143
+ await writeFile(path, String(process.pid), { flag: 'wx' });
144
+ return true;
145
+ }
146
+ catch {
147
+ return false;
148
+ }
149
+ }
150
+ }
151
+ /** CI conventionally sets CI=true; some environments set CI=false to mean "not CI". */
152
+ function isCiEnvironment() {
153
+ const value = process.env['CI']?.trim().toLowerCase();
154
+ return value !== undefined && value !== '' && value !== 'false' && value !== '0';
155
+ }
156
+ function spawnBackgroundRefresh() {
157
+ const script = join(dirname(fileURLToPath(import.meta.url)), 'update-check-refresh.js');
158
+ const child = spawn(process.execPath, [script], { detached: true, stdio: 'ignore' });
159
+ // Spawn failures surface as an async 'error' event; without a listener that
160
+ // becomes an unhandled event that could crash the CLI after its command
161
+ // already succeeded. The refresh is best-effort — swallow it.
162
+ child.on('error', () => { });
163
+ child.unref();
164
+ }
165
+ /**
166
+ * Print a strong upgrade recommendation to stderr when the cached registry
167
+ * state says a newer version is published, and kick off a detached refresh
168
+ * when the cache is stale. Reads only local state — adds no network latency
169
+ * to the command. Never throws.
170
+ */
171
+ export async function maybeNotifyUpdate() {
172
+ try {
173
+ if (process.env['SEQ_STUDIO_NO_UPDATE_CHECK']?.trim())
174
+ return;
175
+ if (isCiEnvironment())
176
+ return;
177
+ if (!process.stderr.isTTY)
178
+ return;
179
+ const cache = await readCache();
180
+ if (!cache || Date.now() - cache.lastCheckedAt > CHECK_INTERVAL_MS) {
181
+ // Exactly one process refreshes: the exclusive claim file elects a
182
+ // single refresher even across concurrent CLI invocations, and the
183
+ // foreground never writes the cache itself (so it can't clobber a
184
+ // fresher background result).
185
+ if (await tryClaimRefresh())
186
+ spawnBackgroundRefresh();
187
+ }
188
+ const latest = cache?.latestVersion;
189
+ const current = currentVersion();
190
+ if (!latest || !isNewerVersion(latest, current))
191
+ return;
192
+ process.stderr.write('\n' +
193
+ `seq-studio ${current} is out of date — ${latest} is available.\n` +
194
+ 'We highly recommend updating: fixes and environment changes land there first.\n' +
195
+ ` pnpm add -g ${PACKAGE_NAME}@latest (or: npm install -g ${PACKAGE_NAME}@latest)\n`);
196
+ }
197
+ catch {
198
+ // Best-effort by design — an update notice must never break a command.
199
+ }
200
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sequenceholdings/studio-cli",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Unified Sequence Studio CLI — `seq-studio process` (Lattice), `seq-studio artifact` (Artifact Studio), `seq-studio functions` / `secrets`, `seq-studio repos` (platform git-service), and `seq-studio auth pat` (git-service PATs). Includes Auth0 browser login shared with seqapi.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -40,8 +40,8 @@
40
40
  "smol-toml": "^1.4.2",
41
41
  "tsx": "^4.20.3",
42
42
  "zod": "^4.1.13",
43
- "@sequenceholdings/artifact-studio": "0.1.11",
44
- "@sequenceholdings/lattice": "0.1.1"
43
+ "@sequenceholdings/artifact-studio": "0.1.12",
44
+ "@sequenceholdings/lattice": "0.1.2"
45
45
  },
46
46
  "peerDependencies": {
47
47
  "@sequenceholdings/orm": "0.1.1"