@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 +24 -2
- package/dist/bin.js +8 -2
- package/dist/config.d.ts +10 -0
- package/dist/config.js +24 -2
- package/dist/envs/commands.js +24 -7
- package/dist/login.d.ts +6 -0
- package/dist/login.js +18 -10
- package/dist/main.js +9 -0
- package/dist/orm/delegate.d.ts +9 -0
- package/dist/orm/delegate.js +21 -2
- package/dist/process/build.d.ts +9 -2
- package/dist/process/build.js +18 -1
- package/dist/process/codegen.js +2 -0
- package/dist/process/commands.d.ts +19 -0
- package/dist/process/commands.js +68 -5
- package/dist/process/discover.d.ts +4 -1
- package/dist/process/discover.js +5 -2
- package/dist/process/lint.js +49 -0
- package/dist/process/repo-install.d.ts +21 -0
- package/dist/process/repo-install.js +83 -0
- package/dist/process/simulate.js +14 -1
- package/dist/update-check-refresh.d.ts +1 -0
- package/dist/update-check-refresh.js +9 -0
- package/dist/update-check.d.ts +27 -0
- package/dist/update-check.js +200 -0
- package/package.json +3 -3
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
|
-
|
|
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
|
-
|
|
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
|
package/dist/envs/commands.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
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:
|
package/dist/orm/delegate.d.ts
CHANGED
|
@@ -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>;
|
package/dist/orm/delegate.js
CHANGED
|
@@ -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
|
-
|
|
46
|
-
|
|
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;
|
package/dist/process/build.d.ts
CHANGED
|
@@ -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
|
|
11
|
+
export interface BuildBundleOptions {
|
|
12
12
|
resolveProcessPin?: ResolveProcessPin;
|
|
13
|
-
|
|
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;
|
package/dist/process/build.js
CHANGED
|
@@ -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
|
|
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,
|
package/dist/process/codegen.js
CHANGED
|
@@ -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>;
|
package/dist/process/commands.js
CHANGED
|
@@ -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
|
-
? ' —
|
|
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[]>;
|
package/dist/process/discover.js
CHANGED
|
@@ -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();
|
package/dist/process/lint.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/process/simulate.js
CHANGED
|
@@ -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.
|
|
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.
|
|
44
|
-
"@sequenceholdings/lattice": "0.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"
|