@sequenceholdings/artifact-studio 0.1.6 → 0.1.10
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/dist/api.js +66 -9
- package/dist/auth.js +1 -1
- package/dist/build-subprocess.d.ts +15 -0
- package/dist/build-subprocess.js +65 -0
- package/dist/build-worker.d.ts +1 -0
- package/dist/build-worker.js +28 -0
- package/dist/build.d.ts +11 -0
- package/dist/build.js +85 -14
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +486 -118
- package/dist/config.d.ts +1 -0
- package/dist/config.js +4 -1
- package/dist/dev-link.d.ts +60 -0
- package/dist/dev-link.js +155 -0
- package/dist/dev-lock.d.ts +5 -0
- package/dist/dev-lock.js +83 -0
- package/dist/git-clone.d.ts +41 -0
- package/dist/git-clone.js +182 -0
- package/dist/git-service-client.d.ts +32 -0
- package/dist/git-service-client.js +122 -0
- package/dist/hash.d.ts +12 -0
- package/dist/hash.js +13 -1
- package/dist/lockfile-origin.d.ts +6 -0
- package/dist/lockfile-origin.js +41 -0
- package/dist/manifest-custom-roles.d.ts +63 -0
- package/dist/manifest-custom-roles.js +194 -0
- package/dist/manifest.d.ts +31 -0
- package/dist/manifest.js +77 -0
- package/dist/prepare-build.d.ts +30 -0
- package/dist/prepare-build.js +119 -0
- package/dist/project.js +2 -1
- package/dist/sandbox-lint.d.ts +21 -0
- package/dist/sandbox-lint.js +95 -0
- package/dist/sanitize-remote-tree.d.ts +19 -0
- package/dist/sanitize-remote-tree.js +52 -0
- package/dist/sdk.d.ts +49 -0
- package/dist/source-resolver.d.ts +68 -0
- package/dist/source-resolver.js +203 -0
- package/dist/templates/react-vite/CLAUDE.md +18 -7
- package/dist/templates/react-vite/package.json +8 -7
- package/dist/trusted-install.d.ts +13 -0
- package/dist/trusted-install.js +186 -0
- package/dist/watch-loop.d.ts +26 -0
- package/dist/watch-loop.js +31 -0
- package/package.json +29 -9
- package/templates/react-vite/CLAUDE.md +18 -7
- package/templates/react-vite/package.json +8 -7
- package/dist/bin.d.ts +0 -2
- package/dist/bin.js +0 -8
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { homedir, tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { stripInstallControlFiles } from './sanitize-remote-tree.js';
|
|
7
|
+
import { assertTrustedArtifactInstallTree } from './trusted-install.js';
|
|
8
|
+
/**
|
|
9
|
+
* Pinned pnpm for remote artifact installs, run via `npx`. Kept in lockstep
|
|
10
|
+
* with the repo's root `packageManager`. Do not float to latest: pnpm 11+ adds
|
|
11
|
+
* an UNCONDITIONAL tarball-URL verification policy (no opt-out) that rejects
|
|
12
|
+
* Chainguard's upstream-proxied tarballs at install time
|
|
13
|
+
* (ERR_PNPM_TARBALL_URL_MISMATCH). We run it via npx — which ships with npm on
|
|
14
|
+
* every Node release — rather than corepack, which Node 25+ no longer bundles.
|
|
15
|
+
*/
|
|
16
|
+
export const ARTIFACT_BUILD_PNPM = 'pnpm@10.28.2';
|
|
17
|
+
/** Chainguard registry routing for ephemeral remote builds. */
|
|
18
|
+
export const ARTIFACT_BUILD_NPMRC = `; Ephemeral install config for seq-studio artifact builds.
|
|
19
|
+
registry=https://libraries.cgr.dev/javascript/
|
|
20
|
+
//libraries.cgr.dev/javascript/:always-auth=true
|
|
21
|
+
//libraries.cgr.dev/javascript-upstream/:always-auth=true
|
|
22
|
+
ignore-scripts=true
|
|
23
|
+
manage-package-manager-versions=false
|
|
24
|
+
`;
|
|
25
|
+
const CHAINGUARD_AUTH_LINE_RE = /^\/\/libraries\.cgr\.dev\/[^:]+:(_auth|_authToken|always-auth)=/;
|
|
26
|
+
/** Pull only Chainguard registry auth lines from the developer ~/.npmrc. */
|
|
27
|
+
export function extractChainguardAuthLines(userNpmrc) {
|
|
28
|
+
const lines = [];
|
|
29
|
+
for (const raw of userNpmrc.split('\n')) {
|
|
30
|
+
const line = raw.trim();
|
|
31
|
+
if (!line || line.startsWith(';') || line.startsWith('#'))
|
|
32
|
+
continue;
|
|
33
|
+
if (CHAINGUARD_AUTH_LINE_RE.test(line))
|
|
34
|
+
lines.push(line);
|
|
35
|
+
}
|
|
36
|
+
return lines;
|
|
37
|
+
}
|
|
38
|
+
/** Merge trusted Chainguard auth from ~/.npmrc with pinned install settings. */
|
|
39
|
+
export function buildControlledArtifactNpmrc(userNpmrcPath = join(homedir(), '.npmrc')) {
|
|
40
|
+
let authBlock = '';
|
|
41
|
+
if (existsSync(userNpmrcPath)) {
|
|
42
|
+
const authLines = extractChainguardAuthLines(readFileSync(userNpmrcPath, 'utf8'));
|
|
43
|
+
if (authLines.length > 0)
|
|
44
|
+
authBlock = `${authLines.join('\n')}\n`;
|
|
45
|
+
}
|
|
46
|
+
return `${authBlock}${ARTIFACT_BUILD_NPMRC}`;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Whether the build should run `pnpm install` before Vite bundles the tree.
|
|
50
|
+
* Only remote materializations (--repo / --git-url) are bare and need install.
|
|
51
|
+
*/
|
|
52
|
+
export function shouldInstallArtifactDependencies(dir, opts = {}) {
|
|
53
|
+
if (!opts.remote)
|
|
54
|
+
return false;
|
|
55
|
+
return existsSync(join(dir, 'package.json'));
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Install artifact runtime deps into the build root. Platform peers (react,
|
|
59
|
+
* atlas-ui, react-query, artifact-studio SDK) are still force-aliased at build
|
|
60
|
+
* time — this step resolves app-specific imports like recharts or reactflow.
|
|
61
|
+
*/
|
|
62
|
+
export async function prepareArtifactBuildRoot(dir, opts = {}) {
|
|
63
|
+
if (!shouldInstallArtifactDependencies(dir, opts))
|
|
64
|
+
return;
|
|
65
|
+
if (!existsSync(join(dir, 'pnpm-lock.yaml'))) {
|
|
66
|
+
throw new Error('Remote artifact deploy requires pnpm-lock.yaml — run `pnpm install` locally and commit the lockfile.');
|
|
67
|
+
}
|
|
68
|
+
// Materialized git-service trees may still carry historical `.npmrc` /
|
|
69
|
+
// `pnpm-workspace.yaml` from early scaffolds. Strip them from the temp
|
|
70
|
+
// build root before the trust check so `--repo` deploy works; authors
|
|
71
|
+
// should still delete those files on main (see artifact-git-deploy skill).
|
|
72
|
+
const stripped = stripInstallControlFiles(dir);
|
|
73
|
+
if (stripped.length > 0) {
|
|
74
|
+
console.warn(`[seq-studio] stripped install-control files from remote tree (not committed): ${stripped.join(', ')}`);
|
|
75
|
+
}
|
|
76
|
+
assertTrustedArtifactInstallTree(dir);
|
|
77
|
+
// Defense-in-depth: never trust a node_modules committed into the checkout.
|
|
78
|
+
// The trust walk skips node_modules, so a crafted tree could smuggle a
|
|
79
|
+
// malicious `node_modules/pnpm` bin. We both (a) remove any pre-existing
|
|
80
|
+
// node_modules and (b) run npx from a trusted cwd below so npx can't resolve
|
|
81
|
+
// the pinned CLI out of the untrusted tree.
|
|
82
|
+
await rm(join(dir, 'node_modules'), { recursive: true, force: true });
|
|
83
|
+
const configDir = await mkdtemp(join(tmpdir(), 'artifact-pnpm-'));
|
|
84
|
+
const controlledNpmrc = join(configDir, '.npmrc');
|
|
85
|
+
const args = ['install', '--frozen-lockfile', '--ignore-scripts', '--prod'];
|
|
86
|
+
const userNpmrcPath = process.env.npm_config_userconfig ?? join(homedir(), '.npmrc');
|
|
87
|
+
console.log(`[seq-studio] installing dependencies (frozen lockfile, prod only) via ${ARTIFACT_BUILD_PNPM}…`);
|
|
88
|
+
try {
|
|
89
|
+
await writeFile(controlledNpmrc, buildControlledArtifactNpmrc(userNpmrcPath), { mode: 0o600 });
|
|
90
|
+
// Run a PINNED pnpm via `npx`, not the operator's ambient `pnpm`. The
|
|
91
|
+
// install must be reproducible across laptops and CI: in a bare temp clone
|
|
92
|
+
// dir the ambient pnpm resolves to latest (11.x), whose unconditional
|
|
93
|
+
// tarball-URL policy rejects Chainguard's upstream-proxied tarballs with
|
|
94
|
+
// ERR_PNPM_TARBALL_URL_MISMATCH. `npx` ships with npm on every Node release
|
|
95
|
+
// (including Node 25+, which dropped corepack), fetches the pinned pnpm, and
|
|
96
|
+
// caches it. `--yes` skips the install-confirmation prompt.
|
|
97
|
+
//
|
|
98
|
+
// SECURITY: run npx from the trusted `configDir` (not `dir`) so it can't
|
|
99
|
+
// resolve/execute a pnpm bin planted in the checkout's node_modules; pnpm
|
|
100
|
+
// still operates on the checkout via `--dir`.
|
|
101
|
+
execFileSync('npx', ['--yes', ARTIFACT_BUILD_PNPM, '--dir', dir, ...args], {
|
|
102
|
+
cwd: configDir,
|
|
103
|
+
stdio: 'inherit',
|
|
104
|
+
env: {
|
|
105
|
+
...process.env,
|
|
106
|
+
npm_config_userconfig: controlledNpmrc,
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
const hint = 'Lockfile out of sync, registry auth missing, or npx unavailable — ' +
|
|
112
|
+
'run `pnpm install` locally and commit pnpm-lock.yaml, and ensure Node ships npm/npx.';
|
|
113
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
114
|
+
throw new Error(`artifact dependency install failed (${ARTIFACT_BUILD_PNPM}). ${hint} (${detail})`);
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
await rm(configDir, { recursive: true, force: true });
|
|
118
|
+
}
|
|
119
|
+
}
|
package/dist/project.js
CHANGED
|
@@ -24,7 +24,8 @@ export async function readArtifactStudioSource(rootDir) {
|
|
|
24
24
|
root,
|
|
25
25
|
manifest,
|
|
26
26
|
files,
|
|
27
|
-
|
|
27
|
+
// Hash the RAW manifest, not the parsed one — see computeSourceHash.
|
|
28
|
+
sourceHash: computeSourceHash({ files, manifest: rawManifest }),
|
|
28
29
|
};
|
|
29
30
|
}
|
|
30
31
|
async function collectSourceFiles(root) {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ArtifactStudioSourceFile } from './project.js';
|
|
2
|
+
/**
|
|
3
|
+
* Static lint for the iframe-sandbox traps that neither the type checker nor
|
|
4
|
+
* `vite build` can see — APIs that work under standalone `vite` dev but silently
|
|
5
|
+
* fail in the deployed artifact (no `allow-modals`/`allow-popups`, CSP
|
|
6
|
+
* `font-src data:` + `connect-src 'self'`, request/response bridge only).
|
|
7
|
+
*
|
|
8
|
+
* Findings are warnings, not errors: they're heuristics (a regex can't prove a
|
|
9
|
+
* `confirm(` is `window.confirm`), so they inform rather than block. Full
|
|
10
|
+
* reference: build-artifact-studio-app/references/sandbox-gotchas.md.
|
|
11
|
+
*/
|
|
12
|
+
export interface SandboxWarning {
|
|
13
|
+
file: string;
|
|
14
|
+
line: number;
|
|
15
|
+
rule: string;
|
|
16
|
+
message: string;
|
|
17
|
+
}
|
|
18
|
+
/** Scan artifact source for sandbox-incompatible APIs. Pure; safe to call often. */
|
|
19
|
+
export declare function lintArtifactSandbox(files: readonly ArtifactStudioSourceFile[]): SandboxWarning[];
|
|
20
|
+
/** Print warnings to stderr in a compact, actionable form. Returns the count. */
|
|
21
|
+
export declare function reportSandboxWarnings(warnings: readonly SandboxWarning[]): number;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
const RULES = [
|
|
2
|
+
{
|
|
3
|
+
name: 'no-native-modals',
|
|
4
|
+
// window.confirm/alert/prompt, or the bare global call (not a method like foo.confirm()).
|
|
5
|
+
pattern: /(?<![\w.])(?:window\.)?(confirm|alert|prompt)\s*\(/g,
|
|
6
|
+
message: 'window.confirm/alert/prompt are blocked in the sandbox (no allow-modals) and silently return — use AlertDialog/ConfirmDialog from @sequenceholdings/atlas-ui.',
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
name: 'no-popups',
|
|
10
|
+
pattern: /(?<![\w.])window\.open\s*\(/g,
|
|
11
|
+
message: 'window.open is blocked in the sandbox (no allow-popups) — navigate in-app or surface the URL as copyable text.',
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
name: 'no-fullscreen',
|
|
15
|
+
pattern: /\.(?:webkit)?requestFullscreen\s*\(/g,
|
|
16
|
+
message: 'The Fullscreen API is blocked in the sandbox — use a CSS overlay (fixed inset-0 z-50) toggled by state.',
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: 'no-remote-fonts',
|
|
20
|
+
pattern: /fonts\.(?:googleapis|gstatic)\.com/g,
|
|
21
|
+
message: 'Remote web fonts will not load (CSP font-src data:), and the deploy ignores index.html. Use the system stack, or inline a base64 data: @font-face.',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: 'no-remote-fontface',
|
|
25
|
+
extensions: ['.css'],
|
|
26
|
+
pattern: /@font-face[^}]*url\(\s*['"]?https?:/gis,
|
|
27
|
+
message: 'A @font-face with an http(s) src is blocked by CSP (font-src data:) — inline the font as a base64 data: URI instead.',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'no-sse',
|
|
31
|
+
pattern: /(?<![\w.])new\s+EventSource\s*\(/g,
|
|
32
|
+
message: 'EventSource/SSE is unavailable — the seq.api bridge is request/response only. Poll on an interval instead.',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'no-websocket',
|
|
36
|
+
pattern: /(?<![\w.])new\s+WebSocket\s*\(/g,
|
|
37
|
+
message: 'WebSocket is unavailable in the sandbox — the seq.api bridge is request/response only. Poll on an interval instead.',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'no-direct-external-fetch',
|
|
41
|
+
pattern: /(?<![\w.])fetch\s*\(\s*['"`]https?:\/\//g,
|
|
42
|
+
message: "Direct fetch() to an external origin is blocked (CSP connect-src 'self') — call Atlas routes via seq.api.fetch.",
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
const LINTABLE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.css', '.html']);
|
|
46
|
+
function extensionOf(path) {
|
|
47
|
+
return path.includes('.') ? path.slice(path.lastIndexOf('.')) : '';
|
|
48
|
+
}
|
|
49
|
+
function lineNumberAt(content, index) {
|
|
50
|
+
let line = 1;
|
|
51
|
+
for (let i = 0; i < index && i < content.length; i++) {
|
|
52
|
+
if (content[i] === '\n')
|
|
53
|
+
line += 1;
|
|
54
|
+
}
|
|
55
|
+
return line;
|
|
56
|
+
}
|
|
57
|
+
/** Scan artifact source for sandbox-incompatible APIs. Pure; safe to call often. */
|
|
58
|
+
export function lintArtifactSandbox(files) {
|
|
59
|
+
const warnings = [];
|
|
60
|
+
for (const file of files) {
|
|
61
|
+
const ext = extensionOf(file.path);
|
|
62
|
+
if (!LINTABLE_EXTENSIONS.has(ext))
|
|
63
|
+
continue;
|
|
64
|
+
for (const rule of RULES) {
|
|
65
|
+
if (rule.extensions && !rule.extensions.includes(ext))
|
|
66
|
+
continue;
|
|
67
|
+
// Fresh lastIndex per file since the rule regexes are global/stateful.
|
|
68
|
+
rule.pattern.lastIndex = 0;
|
|
69
|
+
let match;
|
|
70
|
+
while ((match = rule.pattern.exec(file.content)) !== null) {
|
|
71
|
+
warnings.push({
|
|
72
|
+
file: file.path,
|
|
73
|
+
line: lineNumberAt(file.content, match.index),
|
|
74
|
+
rule: rule.name,
|
|
75
|
+
message: rule.message,
|
|
76
|
+
});
|
|
77
|
+
if (match.index === rule.pattern.lastIndex)
|
|
78
|
+
rule.pattern.lastIndex += 1;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return warnings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
|
|
83
|
+
}
|
|
84
|
+
/** Print warnings to stderr in a compact, actionable form. Returns the count. */
|
|
85
|
+
export function reportSandboxWarnings(warnings) {
|
|
86
|
+
if (warnings.length === 0)
|
|
87
|
+
return 0;
|
|
88
|
+
const noun = warnings.length === 1 ? 'warning' : 'warnings';
|
|
89
|
+
console.warn(`\n[seq-studio] ${warnings.length} sandbox ${noun} (these work in vite dev but fail in the deployed artifact):`);
|
|
90
|
+
for (const warning of warnings) {
|
|
91
|
+
console.warn(` ${warning.file}:${warning.line} [${warning.rule}] ${warning.message}`);
|
|
92
|
+
}
|
|
93
|
+
console.warn(' Reference: build-artifact-studio-app/references/sandbox-gotchas.md\n');
|
|
94
|
+
return warnings.length;
|
|
95
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install-control files that must never influence a remote `--repo` /
|
|
3
|
+
* `--git-url` install. seq-studio pins Chainguard registry + ignore-scripts
|
|
4
|
+
* itself; a committed `.npmrc` / workspace file / pnpm hook would either
|
|
5
|
+
* fail the trust check or let the artifact redirect resolution.
|
|
6
|
+
*
|
|
7
|
+
* We strip them from the *materialized temp tree* (not from the git repo)
|
|
8
|
+
* so a dirty historical tip can still deploy while authors clean main via
|
|
9
|
+
* a Pull that deletes the files for real.
|
|
10
|
+
*
|
|
11
|
+
* The hook/workspace matchers are shared with `assertTrustedArtifactInstallTree`
|
|
12
|
+
* so the strip set cannot drift from the reject set.
|
|
13
|
+
*/
|
|
14
|
+
export declare function listInstallControlFiles(dir: string, prefix?: string): string[];
|
|
15
|
+
/**
|
|
16
|
+
* Delete install-control files from a materialized remote build root.
|
|
17
|
+
* Returns the relative paths that were removed (for logging).
|
|
18
|
+
*/
|
|
19
|
+
export declare function stripInstallControlFiles(dir: string): string[];
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { existsSync, readdirSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { PNPM_HOOK_FILE_RE, PNPM_WORKSPACE_FILE_RE } from './trusted-install.js';
|
|
4
|
+
/**
|
|
5
|
+
* Install-control files that must never influence a remote `--repo` /
|
|
6
|
+
* `--git-url` install. seq-studio pins Chainguard registry + ignore-scripts
|
|
7
|
+
* itself; a committed `.npmrc` / workspace file / pnpm hook would either
|
|
8
|
+
* fail the trust check or let the artifact redirect resolution.
|
|
9
|
+
*
|
|
10
|
+
* We strip them from the *materialized temp tree* (not from the git repo)
|
|
11
|
+
* so a dirty historical tip can still deploy while authors clean main via
|
|
12
|
+
* a Pull that deletes the files for real.
|
|
13
|
+
*
|
|
14
|
+
* The hook/workspace matchers are shared with `assertTrustedArtifactInstallTree`
|
|
15
|
+
* so the strip set cannot drift from the reject set.
|
|
16
|
+
*/
|
|
17
|
+
export function listInstallControlFiles(dir, prefix = '') {
|
|
18
|
+
const found = [];
|
|
19
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
20
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
21
|
+
if (entry.isDirectory()) {
|
|
22
|
+
if (entry.name === 'node_modules' || entry.name === '.git')
|
|
23
|
+
continue;
|
|
24
|
+
found.push(...listInstallControlFiles(join(dir, entry.name), rel));
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (!entry.isFile())
|
|
28
|
+
continue;
|
|
29
|
+
if (rel === '.npmrc' || rel.endsWith('/.npmrc'))
|
|
30
|
+
found.push(rel);
|
|
31
|
+
else if (PNPM_WORKSPACE_FILE_RE.test(rel))
|
|
32
|
+
found.push(rel);
|
|
33
|
+
else if (PNPM_HOOK_FILE_RE.test(rel))
|
|
34
|
+
found.push(rel);
|
|
35
|
+
}
|
|
36
|
+
return found;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Delete install-control files from a materialized remote build root.
|
|
40
|
+
* Returns the relative paths that were removed (for logging).
|
|
41
|
+
*/
|
|
42
|
+
export function stripInstallControlFiles(dir) {
|
|
43
|
+
const removed = [];
|
|
44
|
+
for (const rel of listInstallControlFiles(dir)) {
|
|
45
|
+
const abs = join(dir, rel);
|
|
46
|
+
if (!existsSync(abs))
|
|
47
|
+
continue;
|
|
48
|
+
unlinkSync(abs);
|
|
49
|
+
removed.push(rel);
|
|
50
|
+
}
|
|
51
|
+
return removed;
|
|
52
|
+
}
|
package/dist/sdk.d.ts
CHANGED
|
@@ -36,10 +36,57 @@ export interface SequenceEnvironment {
|
|
|
36
36
|
export interface SequenceAuth {
|
|
37
37
|
hasPermission(permission: string): Promise<boolean>;
|
|
38
38
|
getPermissions(): Promise<string[]>;
|
|
39
|
+
/**
|
|
40
|
+
* The current user's custom-role capabilities on THIS artifact (e.g.
|
|
41
|
+
* `['view', 'edit']` for a reporting tool), resolved server-side via the
|
|
42
|
+
* generic `/api/custom-roles/artifact/:projectId/resolve` endpoint. The host
|
|
43
|
+
* supplies the project id from its trusted context — artifacts cannot point
|
|
44
|
+
* this at another host. Distinct from `getPermissions()`, which returns the
|
|
45
|
+
* user's platform-wide permissions rather than per-artifact role grants.
|
|
46
|
+
* Rejects when the artifact has no project context or the user has no access
|
|
47
|
+
* to the host.
|
|
48
|
+
*/
|
|
49
|
+
getCapabilities(): Promise<string[]>;
|
|
39
50
|
}
|
|
40
51
|
export interface SequenceFlags {
|
|
41
52
|
isEnabled(flagName: string): Promise<boolean>;
|
|
42
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* The Lattice run/node/step this artifact is being served for. Populated by the
|
|
56
|
+
* embedding surface (the Lattice Inbox provides all four; the builder Artifacts
|
|
57
|
+
* tab has no live step). Each field is `null` when the artifact is not embedded
|
|
58
|
+
* in that surface — e.g. a standalone preview has no context at all.
|
|
59
|
+
*/
|
|
60
|
+
export interface SequenceLatticeContext {
|
|
61
|
+
/** The Lattice run the embed belongs to. */
|
|
62
|
+
runId: string | null;
|
|
63
|
+
/** The human node id the artifact is attached to (tells you which node you're serving). */
|
|
64
|
+
nodeId: string | null;
|
|
65
|
+
/** The specific step run (the assignee's task) — the step-scoped id reviewers hold access to. */
|
|
66
|
+
stepId: string | null;
|
|
67
|
+
/** The process the run belongs to. */
|
|
68
|
+
processId: string | null;
|
|
69
|
+
}
|
|
70
|
+
export interface SequenceLattice {
|
|
71
|
+
/**
|
|
72
|
+
* Read the embed context synchronously (no network round trip — the host
|
|
73
|
+
* injects it into the sandbox URL up front). Returns all-null fields when the
|
|
74
|
+
* artifact is not embedded in a Lattice surface.
|
|
75
|
+
*/
|
|
76
|
+
context(): SequenceLatticeContext;
|
|
77
|
+
}
|
|
78
|
+
export interface SequenceHost {
|
|
79
|
+
/**
|
|
80
|
+
* Navigate the parent Atlas shell to an in-app route — e.g.
|
|
81
|
+
* `seq.host.navigate('/crm#/customers/<uid>')`. Fire-and-forget: the artifact
|
|
82
|
+
* sandbox cannot navigate the top frame itself (no `allow-top-navigation`),
|
|
83
|
+
* so this posts the request to the host, which validates it and drives the
|
|
84
|
+
* shell router. The host ignores anything that isn't a same-app relative
|
|
85
|
+
* path (must start with a single `/`), so an artifact can never push the
|
|
86
|
+
* user to an external origin.
|
|
87
|
+
*/
|
|
88
|
+
navigate(path: string): void;
|
|
89
|
+
}
|
|
43
90
|
export interface SequenceArtifactSdk {
|
|
44
91
|
api: SequenceApi;
|
|
45
92
|
ai: {
|
|
@@ -50,6 +97,8 @@ export interface SequenceArtifactSdk {
|
|
|
50
97
|
env: SequenceEnvironment;
|
|
51
98
|
auth: SequenceAuth;
|
|
52
99
|
flags: SequenceFlags;
|
|
100
|
+
lattice: SequenceLattice;
|
|
101
|
+
host: SequenceHost;
|
|
53
102
|
}
|
|
54
103
|
declare global {
|
|
55
104
|
interface Window {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build/deploy source is pluggable. A `deploy`/`build`/`plan` invocation reads
|
|
3
|
+
* its source tree from one of three places, all of which produce the same
|
|
4
|
+
* on-disk tree the rest of the pipeline (build → upload) already consumes:
|
|
5
|
+
*
|
|
6
|
+
* - local folder (default, unchanged): `deploy [dir]`
|
|
7
|
+
* - a platform git-service repo: `deploy --repo <ns>/<name> [--ref <r>]`
|
|
8
|
+
* - any external git URL: `deploy --git-url <url> [--ref <r>]`
|
|
9
|
+
*
|
|
10
|
+
* Remote sources are materialized to a temp dir and torn down after the build.
|
|
11
|
+
*/
|
|
12
|
+
/** Provenance recorded on the deployment row (git_commit/git_branch/git_dirty). */
|
|
13
|
+
export interface SourceProvenance {
|
|
14
|
+
gitCommit: string | null;
|
|
15
|
+
gitBranch: string | null;
|
|
16
|
+
gitDirty: boolean | null;
|
|
17
|
+
}
|
|
18
|
+
export type SourceSpec = {
|
|
19
|
+
kind: 'local';
|
|
20
|
+
dir: string;
|
|
21
|
+
} | {
|
|
22
|
+
kind: 'git-service';
|
|
23
|
+
namespace: string;
|
|
24
|
+
name: string;
|
|
25
|
+
ref?: string;
|
|
26
|
+
} | {
|
|
27
|
+
kind: 'git-url';
|
|
28
|
+
url: string;
|
|
29
|
+
ref?: string;
|
|
30
|
+
};
|
|
31
|
+
export interface ResolvedSource {
|
|
32
|
+
/** Absolute, canonical (realpath'd) directory the build should run against. */
|
|
33
|
+
dir: string;
|
|
34
|
+
/** True for --repo / --git-url materializations (bare tree, needs install). */
|
|
35
|
+
remote: boolean;
|
|
36
|
+
provenance: SourceProvenance;
|
|
37
|
+
/** Remove any temp materialization. No-op for a local source. Never throws. */
|
|
38
|
+
cleanup: () => Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
export interface ParsedFlags {
|
|
41
|
+
positional: string[];
|
|
42
|
+
flags: Record<string, string | true>;
|
|
43
|
+
}
|
|
44
|
+
/** True for sources that must be fetched from a remote (git-service / git-url). */
|
|
45
|
+
export declare function isRemoteSpec(spec: SourceSpec): boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Strip any userinfo (`user:token@`) from a git URL before it's logged or put
|
|
48
|
+
* in an error — an https git URL can carry a PAT in its authority, and leaking
|
|
49
|
+
* it to a terminal or CI log is a credential disclosure.
|
|
50
|
+
*/
|
|
51
|
+
export declare function redactGitUrl(url: string): string;
|
|
52
|
+
/**
|
|
53
|
+
* Derive the source spec from parsed CLI args. Enforces mutual exclusivity:
|
|
54
|
+
* a `[dir]` positional, `--repo`, and `--git-url` are three ways to name the
|
|
55
|
+
* same input, so at most one may be given.
|
|
56
|
+
*/
|
|
57
|
+
export declare function parseSourceSpec(input: ParsedFlags): SourceSpec;
|
|
58
|
+
/**
|
|
59
|
+
* Resolve a spec to an on-disk source directory + provenance. For git-service
|
|
60
|
+
* the caller must supply `baseUrl` + `token`; git-url needs neither (it shells
|
|
61
|
+
* out to `git clone`); local needs nothing.
|
|
62
|
+
*/
|
|
63
|
+
export declare function resolveArtifactSource(spec: SourceSpec, opts?: {
|
|
64
|
+
baseUrl?: string;
|
|
65
|
+
token?: string | null;
|
|
66
|
+
}): Promise<ResolvedSource>;
|
|
67
|
+
/** Working-tree git provenance for a local source (current behavior). */
|
|
68
|
+
export declare function localGitMetadata(dir: string): SourceProvenance;
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { mkdirSync, rmSync } from 'node:fs';
|
|
3
|
+
import { mkdtemp, realpath, rm } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join, resolve } from 'node:path';
|
|
6
|
+
import { materializeRepo, resolveCommitSha, resolveRepo } from './git-service-client.js';
|
|
7
|
+
import { gitServiceCloneUrl, resolveGitPatFromEnv, runGitClone } from './git-clone.js';
|
|
8
|
+
/** True for sources that must be fetched from a remote (git-service / git-url). */
|
|
9
|
+
export function isRemoteSpec(spec) {
|
|
10
|
+
return spec.kind !== 'local';
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Strip any userinfo (`user:token@`) from a git URL before it's logged or put
|
|
14
|
+
* in an error — an https git URL can carry a PAT in its authority, and leaking
|
|
15
|
+
* it to a terminal or CI log is a credential disclosure.
|
|
16
|
+
*/
|
|
17
|
+
export function redactGitUrl(url) {
|
|
18
|
+
try {
|
|
19
|
+
const parsed = new URL(url);
|
|
20
|
+
if (parsed.username || parsed.password) {
|
|
21
|
+
parsed.username = '';
|
|
22
|
+
parsed.password = '';
|
|
23
|
+
return parsed.toString();
|
|
24
|
+
}
|
|
25
|
+
return url;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// Non-standard form (e.g. scp-like git@host:path) — drop a scheme://user@ prefix if present.
|
|
29
|
+
return url.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/@]*@/i, '$1');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function flagString(flags, key) {
|
|
33
|
+
const value = flags[key];
|
|
34
|
+
if (value === true)
|
|
35
|
+
throw new Error(`--${key} requires a value.`);
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Derive the source spec from parsed CLI args. Enforces mutual exclusivity:
|
|
40
|
+
* a `[dir]` positional, `--repo`, and `--git-url` are three ways to name the
|
|
41
|
+
* same input, so at most one may be given.
|
|
42
|
+
*/
|
|
43
|
+
export function parseSourceSpec(input) {
|
|
44
|
+
const repo = flagString(input.flags, 'repo');
|
|
45
|
+
const gitUrl = flagString(input.flags, 'git-url');
|
|
46
|
+
const ref = flagString(input.flags, 'ref');
|
|
47
|
+
const dir = input.positional[0];
|
|
48
|
+
if (repo && gitUrl)
|
|
49
|
+
throw new Error('Pass only one of --repo or --git-url.');
|
|
50
|
+
if ((repo || gitUrl) && dir)
|
|
51
|
+
throw new Error('A [dir] argument cannot be combined with --repo / --git-url.');
|
|
52
|
+
if (ref && !repo && !gitUrl)
|
|
53
|
+
throw new Error('--ref only applies together with --repo or --git-url.');
|
|
54
|
+
if (ref && ref.startsWith('-'))
|
|
55
|
+
throw new Error(`--ref must not start with "-" (got "${ref}").`);
|
|
56
|
+
if (repo) {
|
|
57
|
+
const parts = repo.split('/');
|
|
58
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
59
|
+
throw new Error(`--repo must be "<namespace>/<name>" (got "${repo}").`);
|
|
60
|
+
}
|
|
61
|
+
return { kind: 'git-service', namespace: parts[0], name: parts[1], ref };
|
|
62
|
+
}
|
|
63
|
+
if (gitUrl) {
|
|
64
|
+
// Guard against argument injection: git treats a leading-dash URL as a flag.
|
|
65
|
+
if (gitUrl.startsWith('-'))
|
|
66
|
+
throw new Error(`--git-url must be a URL, not a flag (got "${gitUrl}").`);
|
|
67
|
+
return { kind: 'git-url', url: gitUrl, ref };
|
|
68
|
+
}
|
|
69
|
+
return { kind: 'local', dir: resolve(dir ?? '.') };
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Resolve a spec to an on-disk source directory + provenance. For git-service
|
|
73
|
+
* the caller must supply `baseUrl` + `token`; git-url needs neither (it shells
|
|
74
|
+
* out to `git clone`); local needs nothing.
|
|
75
|
+
*/
|
|
76
|
+
export async function resolveArtifactSource(spec, opts = {}) {
|
|
77
|
+
if (spec.kind === 'local') {
|
|
78
|
+
return { dir: spec.dir, remote: false, provenance: localGitMetadata(spec.dir), cleanup: async () => { } };
|
|
79
|
+
}
|
|
80
|
+
const dest = await makeTempDir();
|
|
81
|
+
const cleanup = () => removeDir(dest);
|
|
82
|
+
try {
|
|
83
|
+
if (spec.kind === 'git-service') {
|
|
84
|
+
if (!opts.baseUrl || !opts.token) {
|
|
85
|
+
throw new Error('Deploying from --repo needs a target environment and auth. ' +
|
|
86
|
+
'Pass --env and run `seq-studio login`.');
|
|
87
|
+
}
|
|
88
|
+
// resolveRepo + resolveCommitSha use the Auth0/M2M token: the clone URL
|
|
89
|
+
// is id-addressed (no by-path endpoint), and we pin the build to the
|
|
90
|
+
// resolved SHA — the same commit recorded as provenance — rather than the
|
|
91
|
+
// mutable ref, matching the prior materialize behavior.
|
|
92
|
+
const repo = await resolveRepo({ baseUrl: opts.baseUrl, token: opts.token, namespace: spec.namespace, name: spec.name });
|
|
93
|
+
const ref = spec.ref ?? repo.defaultBranch;
|
|
94
|
+
const sha = await resolveCommitSha({ baseUrl: opts.baseUrl, token: opts.token, repoId: repo.id, ref });
|
|
95
|
+
// Human path: materialize via a smart-HTTP `git clone` (one pack
|
|
96
|
+
// transfer). The earlier per-blob JSON walk was O(files) serial HTTP
|
|
97
|
+
// round-trips and stalled/failed on large artifacts. Smart-HTTP
|
|
98
|
+
// authenticates by PAT (Basic-auth password), NOT the Auth0 bearer — the
|
|
99
|
+
// same token you clone the repo with. No PAT, no git workflow.
|
|
100
|
+
const pat = resolveGitPatFromEnv();
|
|
101
|
+
if (pat) {
|
|
102
|
+
const cloneUrl = gitServiceCloneUrl(opts.baseUrl, repo.id);
|
|
103
|
+
await runGitClone({ cloneUrl, destDir: dest, pat, ref: sha });
|
|
104
|
+
console.log(`[seq-studio] source: ${spec.namespace}/${spec.name}@${ref} (${sha.slice(0, 10)}, git clone)`);
|
|
105
|
+
return { dir: dest, remote: true, provenance: { gitCommit: sha, gitBranch: ref, gitDirty: false }, cleanup };
|
|
106
|
+
}
|
|
107
|
+
// CI/system path: the trusted M2M service account can't own a PAT (PAT
|
|
108
|
+
// issuance is gated on a human Auth0 user row), so it keeps the JSON
|
|
109
|
+
// materialize path it has always used, authenticated by its M2M token.
|
|
110
|
+
// Interactive callers (no PAT, no M2M) fall through to the hard error.
|
|
111
|
+
// TODO: teach the git-service smart-HTTP endpoint to accept the trusted
|
|
112
|
+
// M2M identity so CI can use the fast clone path too.
|
|
113
|
+
if (process.env.AUTH0_M2M_CLIENT_SECRET?.trim()) {
|
|
114
|
+
const count = await materializeRepo({ baseUrl: opts.baseUrl, token: opts.token, repoId: repo.id, ref: sha, destDir: dest });
|
|
115
|
+
console.log(`[seq-studio] source: ${spec.namespace}/${spec.name}@${ref} (${sha.slice(0, 10)}, ${count} file(s), M2M)`);
|
|
116
|
+
return { dir: dest, remote: true, provenance: { gitCommit: sha, gitBranch: ref, gitDirty: false }, cleanup };
|
|
117
|
+
}
|
|
118
|
+
throw new Error('Deploying from --repo requires a git PAT. Set ATLAS_GIT_PAT to a token with the ' +
|
|
119
|
+
'repo:read scope (create one in Atlas → Settings → Tokens, or `seq-studio auth pat create`). ' +
|
|
120
|
+
'The git-service clone uses smart-HTTP, which authenticates by PAT — the same token you clone the repo with.');
|
|
121
|
+
}
|
|
122
|
+
const { sha, branch } = cloneGitUrl(spec.url, spec.ref, dest);
|
|
123
|
+
console.log(`[seq-studio] source: ${redactGitUrl(spec.url)}${spec.ref ? `@${spec.ref}` : ''} (${sha.slice(0, 10)})`);
|
|
124
|
+
return { dir: dest, remote: true, provenance: { gitCommit: sha, gitBranch: branch, gitDirty: false }, cleanup };
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
await cleanup();
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** Working-tree git provenance for a local source (current behavior). */
|
|
132
|
+
export function localGitMetadata(dir) {
|
|
133
|
+
const gitOpts = {
|
|
134
|
+
cwd: dir,
|
|
135
|
+
encoding: 'utf8',
|
|
136
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
137
|
+
};
|
|
138
|
+
try {
|
|
139
|
+
const gitCommit = String(execFileSync('git', ['rev-parse', 'HEAD'], gitOpts)).trim();
|
|
140
|
+
const gitBranch = String(execFileSync('git', ['branch', '--show-current'], gitOpts)).trim();
|
|
141
|
+
const gitDirty = String(execFileSync('git', ['status', '--porcelain'], gitOpts)).trim().length > 0;
|
|
142
|
+
return { gitCommit, gitBranch, gitDirty };
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return { gitCommit: null, gitBranch: null, gitDirty: null };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
async function makeTempDir() {
|
|
149
|
+
const dir = await mkdtemp(join(tmpdir(), 'artifact-src-'));
|
|
150
|
+
// Canonicalize: the Vite html plugin emits the entry file name relative to
|
|
151
|
+
// process.cwd() and rejects `..` segments. os.tmpdir() resolves through
|
|
152
|
+
// symlinks on macOS (/tmp -> /private/tmp, /var -> /private/var), so without
|
|
153
|
+
// realpath the build root and the realpath'd html input mismatch and the
|
|
154
|
+
// build fails with a "must not be a relative path" error.
|
|
155
|
+
return realpath(dir);
|
|
156
|
+
}
|
|
157
|
+
async function removeDir(dir) {
|
|
158
|
+
try {
|
|
159
|
+
await rm(dir, { recursive: true, force: true });
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
console.warn(`[seq-studio] could not remove temp source dir ${dir}: ${error instanceof Error ? error.message : error}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function cloneGitUrl(url, ref, dest) {
|
|
166
|
+
const opts = {
|
|
167
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
168
|
+
encoding: 'utf8',
|
|
169
|
+
};
|
|
170
|
+
const git = (args) => String(execFileSync('git', args, opts)).trim();
|
|
171
|
+
try {
|
|
172
|
+
if (ref) {
|
|
173
|
+
try {
|
|
174
|
+
// Fast path: shallow single-branch clone (works for a branch or tag).
|
|
175
|
+
git(['clone', '--depth', '1', '--single-branch', '--branch', ref, '--', url, dest]);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// A commit SHA can't be `--branch`ed — full clone, then checkout. Reset
|
|
179
|
+
// the destination first: a failed shallow attempt can leave partial
|
|
180
|
+
// contents, which would make `git clone` reject a non-empty directory.
|
|
181
|
+
rmSync(dest, { recursive: true, force: true });
|
|
182
|
+
mkdirSync(dest, { recursive: true });
|
|
183
|
+
git(['clone', '--', url, dest]);
|
|
184
|
+
git(['-C', dest, 'checkout', ref]);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
git(['clone', '--depth', '1', '--', url, dest]);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
// Never surface the raw URL — an https git URL can carry a PAT in userinfo.
|
|
193
|
+
// Scrub any occurrence of it from git's stderr/message too.
|
|
194
|
+
const safeUrl = redactGitUrl(url);
|
|
195
|
+
const detail = error.stderr?.trim() || (error instanceof Error ? error.message : String(error));
|
|
196
|
+
const safeDetail = detail.split(url).join(safeUrl);
|
|
197
|
+
throw new Error(`git clone failed for ${safeUrl}${ref ? `@${ref}` : ''}: ${safeDetail}`);
|
|
198
|
+
}
|
|
199
|
+
const sha = git(['-C', dest, 'rev-parse', 'HEAD']);
|
|
200
|
+
const abbrev = git(['-C', dest, 'rev-parse', '--abbrev-ref', 'HEAD']);
|
|
201
|
+
const branch = ref ?? (abbrev && abbrev !== 'HEAD' ? abbrev : null);
|
|
202
|
+
return { sha, branch };
|
|
203
|
+
}
|