@sequenceholdings/artifact-studio 0.1.6 → 0.1.9

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.
Files changed (49) hide show
  1. package/dist/api.js +66 -9
  2. package/dist/auth.js +1 -1
  3. package/dist/build-subprocess.d.ts +15 -0
  4. package/dist/build-subprocess.js +65 -0
  5. package/dist/build-worker.d.ts +1 -0
  6. package/dist/build-worker.js +28 -0
  7. package/dist/build.d.ts +11 -0
  8. package/dist/build.js +85 -14
  9. package/dist/cli.d.ts +1 -0
  10. package/dist/cli.js +486 -118
  11. package/dist/config.d.ts +1 -0
  12. package/dist/config.js +4 -1
  13. package/dist/dev-link.d.ts +60 -0
  14. package/dist/dev-link.js +155 -0
  15. package/dist/dev-lock.d.ts +5 -0
  16. package/dist/dev-lock.js +83 -0
  17. package/dist/git-clone.d.ts +41 -0
  18. package/dist/git-clone.js +178 -0
  19. package/dist/git-service-client.d.ts +32 -0
  20. package/dist/git-service-client.js +122 -0
  21. package/dist/hash.d.ts +12 -0
  22. package/dist/hash.js +13 -1
  23. package/dist/lockfile-origin.d.ts +6 -0
  24. package/dist/lockfile-origin.js +41 -0
  25. package/dist/manifest-custom-roles.d.ts +63 -0
  26. package/dist/manifest-custom-roles.js +194 -0
  27. package/dist/manifest.d.ts +31 -0
  28. package/dist/manifest.js +77 -0
  29. package/dist/prepare-build.d.ts +21 -0
  30. package/dist/prepare-build.js +94 -0
  31. package/dist/project.js +2 -1
  32. package/dist/sandbox-lint.d.ts +21 -0
  33. package/dist/sandbox-lint.js +95 -0
  34. package/dist/sanitize-remote-tree.d.ts +19 -0
  35. package/dist/sanitize-remote-tree.js +52 -0
  36. package/dist/sdk.d.ts +49 -0
  37. package/dist/source-resolver.d.ts +68 -0
  38. package/dist/source-resolver.js +202 -0
  39. package/dist/templates/react-vite/CLAUDE.md +18 -7
  40. package/dist/templates/react-vite/package.json +8 -7
  41. package/dist/trusted-install.d.ts +13 -0
  42. package/dist/trusted-install.js +186 -0
  43. package/dist/watch-loop.d.ts +26 -0
  44. package/dist/watch-loop.js +31 -0
  45. package/package.json +29 -9
  46. package/templates/react-vite/CLAUDE.md +18 -7
  47. package/templates/react-vite/package.json +8 -7
  48. package/dist/bin.d.ts +0 -2
  49. package/dist/bin.js +0 -8
package/dist/config.d.ts CHANGED
@@ -13,6 +13,7 @@ export interface TokenConfig {
13
13
  export declare function globalConfigDir(): string;
14
14
  export declare function tokenConfigPath(): string;
15
15
  export declare function localConfigPath(cwd?: string): string;
16
+ export declare function devLockPath(cwd?: string): string;
16
17
  export declare function readLocalConfig(cwd?: string): Promise<LocalProjectConfig>;
17
18
  export declare function writeLocalConfig(config: LocalProjectConfig, cwd?: string): Promise<void>;
18
19
  export declare function readTokenConfig(): Promise<TokenConfig>;
package/dist/config.js CHANGED
@@ -17,6 +17,9 @@ export function tokenConfigPath() {
17
17
  export function localConfigPath(cwd = process.cwd()) {
18
18
  return join(resolve(cwd), '.artifact-studio', 'config.json');
19
19
  }
20
+ export function devLockPath(cwd = process.cwd()) {
21
+ return join(resolve(cwd), '.artifact-studio', 'dev.lock');
22
+ }
20
23
  export async function readLocalConfig(cwd = process.cwd()) {
21
24
  const path = localConfigPath(cwd);
22
25
  if (!existsSync(path))
@@ -41,7 +44,7 @@ export async function writeTokenConfig(config) {
41
44
  }
42
45
  export function resolveEnvironment(name, fallback) {
43
46
  const envName = name ?? fallback;
44
- // Allow `seq-lattice artifact` (and any other embedder that wants to
47
+ // Allow `seq-studio artifact` (and any other embedder that wants to
45
48
  // route artifact-studio through a centrally-managed env map) to
46
49
  // override the base URL without monkey-patching this file. The env
47
50
  // name remains the user-supplied one so log lines / project records
@@ -0,0 +1,60 @@
1
+ /**
2
+ * `seq-studio artifact dev --link <pkgDir>` support.
3
+ *
4
+ * Artifacts consume shared libraries (notably `@sequenceholdings/atlas-ui`) as a
5
+ * prebuilt `dist` bundle, resolved from the artifact's `node_modules`. That makes
6
+ * editing a shared component a 4-step manual dance: rebuild the lib, force a
7
+ * reinstall so the new `dist` lands in the artifact's `node_modules`, restart the
8
+ * watcher (its source hash only covers artifact files, not deps), and refresh.
9
+ *
10
+ * `--link` collapses that to zero steps: the watcher hashes the linked package's
11
+ * source each tick, and on change rebuilds the package and copies its fresh `dist`
12
+ * into the exact location the artifact build resolves it from — then forces an
13
+ * artifact rebuild + preview push.
14
+ */
15
+ export interface LinkedDep {
16
+ /** Absolute path to the linked source package (e.g. shared/services/atlas-ui). */
17
+ dir: string;
18
+ /** Package name from its package.json (e.g. @sequenceholdings/atlas-ui). */
19
+ name: string;
20
+ /** Absolute path to the package's source dir, watched for changes. */
21
+ srcDir: string;
22
+ /** Absolute path to the package's dist dir, produced by its build script. */
23
+ distDir: string;
24
+ /** Absolute path to the dist dir the artifact build actually resolves from. */
25
+ targetDistDir: string;
26
+ /** True when the artifact resolves the package straight from `dir` (symlinked file: dep). */
27
+ inPlace: boolean;
28
+ }
29
+ /** Resolve `${name}/package.json` the same way the build worker does. */
30
+ type PackageResolver = (request: string) => string;
31
+ /** Split a `--link a,b` flag value into trimmed, non-empty paths. */
32
+ export declare function parseLinks(flag: string | true | undefined): string[];
33
+ /**
34
+ * Turn `--link` paths into resolved link descriptors. Each path is resolved
35
+ * relative to `cwd` (where the user invoked the CLI). The artifact's installed
36
+ * copy of the package is located via the same module resolution the build uses,
37
+ * so the copied `dist` lands exactly where the build will read it.
38
+ */
39
+ export declare function resolveLinkedDeps({ links, cwd, resolver, readPackageName, }: {
40
+ links: string[];
41
+ cwd?: string;
42
+ resolver?: PackageResolver;
43
+ readPackageName?: (pkgDir: string) => string;
44
+ }): LinkedDep[];
45
+ /**
46
+ * Content-hash the watched source of every linked package. The watcher compares
47
+ * this between ticks to decide whether a linked package changed and must be
48
+ * rebuilt — analogous to the artifact's own source hash.
49
+ */
50
+ export declare function hashLinkedSources(deps: readonly LinkedDep[]): Promise<string>;
51
+ /** Run a linked package's `build` script in a child process. */
52
+ export declare function rebuildLinkedDep(dep: LinkedDep, signal?: AbortSignal): Promise<void>;
53
+ /**
54
+ * Copy a linked package's freshly built `dist` into the location the artifact
55
+ * build resolves it from. A no-op when the artifact already resolves the package
56
+ * in place (symlinked file: dep), where the rebuild already wrote the bytes the
57
+ * build will read.
58
+ */
59
+ export declare function syncLinkedDist(dep: LinkedDep): Promise<void>;
60
+ export {};
@@ -0,0 +1,155 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
4
+ import { cp, readFile, readdir, rm } from 'node:fs/promises';
5
+ import { createRequire } from 'node:module';
6
+ import { dirname, join, relative, resolve } from 'node:path';
7
+ const WATCHED_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.css', '.json']);
8
+ const IGNORED_DIRS = new Set(['node_modules', '.git', 'dist', '.next', '.turbo', '.cache']);
9
+ const defaultResolver = createRequire(import.meta.url).resolve;
10
+ /** Split a `--link a,b` flag value into trimmed, non-empty paths. */
11
+ export function parseLinks(flag) {
12
+ if (typeof flag !== 'string')
13
+ return [];
14
+ return flag
15
+ .split(',')
16
+ .map((entry) => entry.trim())
17
+ .filter(Boolean);
18
+ }
19
+ /**
20
+ * Turn `--link` paths into resolved link descriptors. Each path is resolved
21
+ * relative to `cwd` (where the user invoked the CLI). The artifact's installed
22
+ * copy of the package is located via the same module resolution the build uses,
23
+ * so the copied `dist` lands exactly where the build will read it.
24
+ */
25
+ export function resolveLinkedDeps({ links, cwd = process.cwd(), resolver = defaultResolver, readPackageName = defaultReadPackageName, }) {
26
+ return links.map((link) => {
27
+ const dir = resolve(cwd, link);
28
+ if (!existsSync(dir))
29
+ throw new Error(`--link path does not exist: ${dir}`);
30
+ const name = readPackageName(dir);
31
+ let targetPkgDir;
32
+ try {
33
+ targetPkgDir = dirname(resolver(`${name}/package.json`));
34
+ }
35
+ catch {
36
+ throw new Error(`Cannot resolve "${name}" from the artifact — is it listed as a dependency? (--link ${link})`);
37
+ }
38
+ return {
39
+ dir,
40
+ name,
41
+ srcDir: join(dir, 'src'),
42
+ distDir: join(dir, 'dist'),
43
+ targetDistDir: join(targetPkgDir, 'dist'),
44
+ inPlace: samePath(targetPkgDir, dir),
45
+ };
46
+ });
47
+ }
48
+ function defaultReadPackageName(pkgDir) {
49
+ const pkgJsonPath = join(pkgDir, 'package.json');
50
+ if (!existsSync(pkgJsonPath))
51
+ throw new Error(`No package.json found at ${pkgJsonPath}`);
52
+ const parsed = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
53
+ if (!parsed.name)
54
+ throw new Error(`package.json at ${pkgJsonPath} has no "name"`);
55
+ return parsed.name;
56
+ }
57
+ function samePath(a, b) {
58
+ try {
59
+ return realpathSync(a) === realpathSync(b);
60
+ }
61
+ catch {
62
+ return resolve(a) === resolve(b);
63
+ }
64
+ }
65
+ /**
66
+ * Content-hash the watched source of every linked package. The watcher compares
67
+ * this between ticks to decide whether a linked package changed and must be
68
+ * rebuilt — analogous to the artifact's own source hash.
69
+ */
70
+ export async function hashLinkedSources(deps) {
71
+ const hash = createHash('sha256');
72
+ for (const dep of deps) {
73
+ hash.update(dep.name);
74
+ hash.update('\0');
75
+ const files = (await collectFiles(dep.srcDir)).sort((a, b) => a.localeCompare(b));
76
+ for (const file of files) {
77
+ hash.update(relative(dep.srcDir, file));
78
+ hash.update('\0');
79
+ hash.update(await readFile(file));
80
+ hash.update('\0');
81
+ }
82
+ }
83
+ return hash.digest('hex');
84
+ }
85
+ async function collectFiles(root) {
86
+ if (!existsSync(root))
87
+ return [];
88
+ const out = [];
89
+ async function walk(dir) {
90
+ const entries = await readdir(dir, { withFileTypes: true });
91
+ for (const entry of entries) {
92
+ const absolute = join(dir, entry.name);
93
+ if (entry.isDirectory()) {
94
+ if (!IGNORED_DIRS.has(entry.name))
95
+ await walk(absolute);
96
+ continue;
97
+ }
98
+ if (!entry.isFile())
99
+ continue;
100
+ const ext = entry.name.includes('.') ? entry.name.slice(entry.name.lastIndexOf('.')) : '';
101
+ if (WATCHED_EXTENSIONS.has(ext))
102
+ out.push(absolute);
103
+ }
104
+ }
105
+ await walk(root);
106
+ return out;
107
+ }
108
+ /** Run a linked package's `build` script in a child process. */
109
+ export function rebuildLinkedDep(dep, signal) {
110
+ return new Promise((resolveBuild, reject) => {
111
+ if (signal?.aborted) {
112
+ reject(new Error('build aborted'));
113
+ return;
114
+ }
115
+ const child = spawn('pnpm', ['run', 'build'], {
116
+ cwd: dep.dir,
117
+ stdio: ['ignore', 'inherit', 'inherit'],
118
+ });
119
+ const onAbort = () => {
120
+ child.kill('SIGTERM');
121
+ };
122
+ signal?.addEventListener('abort', onAbort, { once: true });
123
+ child.on('error', (error) => {
124
+ signal?.removeEventListener('abort', onAbort);
125
+ reject(error);
126
+ });
127
+ child.on('close', (code) => {
128
+ signal?.removeEventListener('abort', onAbort);
129
+ if (signal?.aborted) {
130
+ reject(new Error('build aborted'));
131
+ return;
132
+ }
133
+ if (code === 0) {
134
+ resolveBuild();
135
+ return;
136
+ }
137
+ reject(new Error(`${dep.name} build exited with code ${code ?? 'null'}`));
138
+ });
139
+ });
140
+ }
141
+ /**
142
+ * Copy a linked package's freshly built `dist` into the location the artifact
143
+ * build resolves it from. A no-op when the artifact already resolves the package
144
+ * in place (symlinked file: dep), where the rebuild already wrote the bytes the
145
+ * build will read.
146
+ */
147
+ export async function syncLinkedDist(dep) {
148
+ if (dep.inPlace)
149
+ return;
150
+ if (!existsSync(dep.distDir)) {
151
+ throw new Error(`${dep.name} build produced no dist at ${dep.distDir}`);
152
+ }
153
+ await rm(dep.targetDistDir, { recursive: true, force: true });
154
+ await cp(dep.distDir, dep.targetDistDir, { recursive: true });
155
+ }
@@ -0,0 +1,5 @@
1
+ export declare function acquireDevLock({ dir, previewKey, log, }: {
2
+ dir: string;
3
+ previewKey?: string;
4
+ log?: (message: string) => void;
5
+ }): Promise<() => void>;
@@ -0,0 +1,83 @@
1
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { setTimeout as delay } from 'node:timers/promises';
4
+ import { devLockPath } from './config.js';
5
+ function isProcessAlive(pid) {
6
+ if (!Number.isInteger(pid) || pid <= 0)
7
+ return false;
8
+ try {
9
+ // Signal 0 performs existence/permission checks without delivering a signal.
10
+ process.kill(pid, 0);
11
+ return true;
12
+ }
13
+ catch (error) {
14
+ // EPERM means the process exists but is owned by another user — still alive.
15
+ return error.code === 'EPERM';
16
+ }
17
+ }
18
+ function readDevLock(path) {
19
+ if (!existsSync(path))
20
+ return null;
21
+ try {
22
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
23
+ return typeof parsed?.pid === 'number' ? parsed : null;
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ async function waitForExit(pid, timeoutMs) {
30
+ const deadline = Date.now() + timeoutMs;
31
+ while (Date.now() < deadline) {
32
+ if (!isProcessAlive(pid))
33
+ return;
34
+ await delay(100);
35
+ }
36
+ // Last resort: a watcher that ignored SIGTERM gets force-killed so it can't
37
+ // keep racing on the token file.
38
+ if (isProcessAlive(pid)) {
39
+ try {
40
+ process.kill(pid, 'SIGKILL');
41
+ }
42
+ catch {
43
+ // Raced to exit between the check and the kill — already gone.
44
+ }
45
+ }
46
+ }
47
+ export async function acquireDevLock({ dir, previewKey, log = console.log, }) {
48
+ const path = devLockPath(dir);
49
+ const existing = readDevLock(path);
50
+ if (existing && existing.pid !== process.pid && isProcessAlive(existing.pid)) {
51
+ log(`[seq-studio] stopping previous dev watcher (pid ${existing.pid})`);
52
+ try {
53
+ process.kill(existing.pid, 'SIGTERM');
54
+ }
55
+ catch {
56
+ // Exited between read and kill — nothing to stop.
57
+ }
58
+ await waitForExit(existing.pid, 3000);
59
+ }
60
+ mkdirSync(dirname(path), { recursive: true });
61
+ const lock = {
62
+ pid: process.pid,
63
+ startedAt: new Date().toISOString(),
64
+ ...(previewKey ? { previewKey } : {}),
65
+ };
66
+ writeFileSync(path, JSON.stringify(lock, null, 2), 'utf8');
67
+ let released = false;
68
+ return () => {
69
+ if (released)
70
+ return;
71
+ released = true;
72
+ try {
73
+ // Only remove the lock if it's still ours — a newer watcher may have
74
+ // already taken over and we must not delete its lock.
75
+ const current = readDevLock(path);
76
+ if (current?.pid === process.pid)
77
+ unlinkSync(path);
78
+ }
79
+ catch {
80
+ // Best-effort cleanup; never throw from a shutdown path.
81
+ }
82
+ };
83
+ }
@@ -0,0 +1,41 @@
1
+ /** Read the git PAT from the environment (`ATLAS_GIT_PAT`), trimmed. */
2
+ export declare function resolveGitPatFromEnv(): string | undefined;
3
+ /** Full or abbreviated hex SHAs — not valid as `git clone --branch`. */
4
+ export declare function looksLikeCommitSha(ref: string): boolean;
5
+ /**
6
+ * Smart-HTTP clone base for a git-service repo id (aligned with PLA-83:
7
+ * `<origin>/api/git-service/repos/<id>/git`). Git appends `/info/refs` and
8
+ * `/git-upload-pack` itself.
9
+ */
10
+ export declare function gitServiceCloneUrl(baseUrl: string, repoId: string): string;
11
+ export declare function redactCloneUrl(url: string): string;
12
+ export type RunGitClone = (args: {
13
+ /** Clean HTTPS clone URL — no embedded credentials. */
14
+ cloneUrl: string;
15
+ destDir: string;
16
+ /** Optional branch, tag, or commit SHA. */
17
+ ref?: string;
18
+ /** PAT used as Basic-auth password (username `git`). */
19
+ pat: string;
20
+ /** Hard ceiling before the git child is killed (default 5m). */
21
+ timeoutMs?: number;
22
+ }) => Promise<void>;
23
+ /**
24
+ * Run `fn` with GIT_ASKPASS set to a short-lived script that answers username
25
+ * with `git` and password with `pat`. The script is deleted afterward.
26
+ */
27
+ export declare function withGitAskpass<T>({ pat, fn, }: {
28
+ pat: string;
29
+ fn: (env: NodeJS.ProcessEnv) => Promise<T>;
30
+ }): Promise<T>;
31
+ /**
32
+ * Clone with a clean URL + PAT via askpass. Branch/tag refs use `--branch`;
33
+ * commit SHAs clone default HEAD then `git checkout <sha>`.
34
+ */
35
+ export declare function runGitClone({ cloneUrl, destDir, ref, pat, timeoutMs, }: {
36
+ cloneUrl: string;
37
+ destDir: string;
38
+ ref?: string;
39
+ pat: string;
40
+ timeoutMs?: number;
41
+ }): Promise<void>;
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Smart-HTTP git clone helpers for the platform git-service.
3
+ *
4
+ * This is the canonical implementation. `@sequenceholdings/studio-cli`
5
+ * re-exports it from `src/repos/git-clone.ts` so the `repos` tool and the
6
+ * artifact `--repo` deploy path share one clone path.
7
+ *
8
+ * Credentials are supplied via an ephemeral GIT_ASKPASS script so the PAT
9
+ * never lands in `remote.origin.url` or the git argv. Callers pass a clean
10
+ * HTTPS clone URL plus the PAT separately.
11
+ */
12
+ import { spawn } from 'node:child_process';
13
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
14
+ import { tmpdir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ /** Read the git PAT from the environment (`ATLAS_GIT_PAT`), trimmed. */
17
+ export function resolveGitPatFromEnv() {
18
+ const value = process.env.ATLAS_GIT_PAT?.trim();
19
+ return value || undefined;
20
+ }
21
+ /** Full or abbreviated hex SHAs — not valid as `git clone --branch`. */
22
+ export function looksLikeCommitSha(ref) {
23
+ return /^[0-9a-f]{7,40}$/i.test(ref);
24
+ }
25
+ /**
26
+ * Smart-HTTP clone base for a git-service repo id (aligned with PLA-83:
27
+ * `<origin>/api/git-service/repos/<id>/git`). Git appends `/info/refs` and
28
+ * `/git-upload-pack` itself.
29
+ */
30
+ export function gitServiceCloneUrl(baseUrl, repoId) {
31
+ return `${baseUrl.replace(/\/+$/, '')}/api/git-service/repos/${repoId}/git`;
32
+ }
33
+ export function redactCloneUrl(url) {
34
+ try {
35
+ const parsed = new URL(url);
36
+ if (parsed.password)
37
+ parsed.password = '***';
38
+ return parsed.toString();
39
+ }
40
+ catch {
41
+ return url.replace(/:[^/@]+@/, ':***@');
42
+ }
43
+ }
44
+ /**
45
+ * Abort an HTTP transfer that drops below ~1 KB/s for 30s — the primary stall
46
+ * guard, so a wedged smart-HTTP fetch fails cleanly in ~30s instead of hanging
47
+ * a deploy/CI. Passed as `git -c` so it applies to the clone's fetch.
48
+ */
49
+ const GIT_LOW_SPEED_LIMIT_BYTES = 1000;
50
+ const GIT_LOW_SPEED_TIME_SEC = 30;
51
+ /** Backstop hard deadline: kill the child even if it trickles just above the low-speed floor. */
52
+ const GIT_CLONE_TIMEOUT_MS = 5 * 60_000;
53
+ /** First non-flag arg (the git subcommand), skipping `-c <value>` config pairs. */
54
+ function gitVerb(args) {
55
+ for (let i = 0; i < args.length; i++) {
56
+ const a = args[i];
57
+ if (a === '-c') {
58
+ i += 1; // skip the config value that follows -c
59
+ continue;
60
+ }
61
+ if (!a.startsWith('-'))
62
+ return a;
63
+ }
64
+ return 'git';
65
+ }
66
+ async function spawnGit({ args, env, cwd, timeoutMs = GIT_CLONE_TIMEOUT_MS, }) {
67
+ const verb = gitVerb(args);
68
+ await new Promise((resolve, reject) => {
69
+ const child = spawn('git', args, {
70
+ cwd,
71
+ stdio: ['ignore', 'pipe', 'pipe'],
72
+ env,
73
+ });
74
+ let stderr = '';
75
+ let timedOut = false;
76
+ const timer = setTimeout(() => {
77
+ timedOut = true;
78
+ child.kill('SIGKILL');
79
+ }, timeoutMs);
80
+ child.stderr?.on('data', (chunk) => {
81
+ stderr += chunk.toString();
82
+ });
83
+ child.on('error', (err) => {
84
+ clearTimeout(timer);
85
+ reject(err);
86
+ });
87
+ child.on('close', (code) => {
88
+ clearTimeout(timer);
89
+ if (timedOut) {
90
+ reject(new Error(`git ${verb} exceeded ${Math.round(timeoutMs / 1000)}s and was terminated (transport stalled)`));
91
+ return;
92
+ }
93
+ if (code === 0) {
94
+ resolve();
95
+ return;
96
+ }
97
+ reject(new Error(`git ${verb} failed (exit ${code ?? 'null'}): ${stderr.trim() || 'no stderr'}`));
98
+ });
99
+ });
100
+ }
101
+ /**
102
+ * Run `fn` with GIT_ASKPASS set to a short-lived script that answers username
103
+ * with `git` and password with `pat`. The script is deleted afterward.
104
+ */
105
+ export async function withGitAskpass({ pat, fn, }) {
106
+ const dir = await mkdtemp(join(tmpdir(), 'artifact-askpass-'));
107
+ const scriptPath = join(dir, 'askpass.sh');
108
+ const script = [
109
+ '#!/bin/sh',
110
+ 'case "$1" in',
111
+ " *[Uu]sername*) printf '%s\\n' git ;;",
112
+ ' *) printf \'%s\\n\' "$SEQ_STUDIO_GIT_ASKPASS_PASSWORD" ;;',
113
+ 'esac',
114
+ '',
115
+ ].join('\n');
116
+ await writeFile(scriptPath, script, { mode: 0o700 });
117
+ try {
118
+ return await fn({
119
+ ...process.env,
120
+ GIT_ASKPASS: scriptPath,
121
+ GIT_TERMINAL_PROMPT: '0',
122
+ SEQ_STUDIO_GIT_ASKPASS_PASSWORD: pat,
123
+ GIT_CONFIG_COUNT: '1',
124
+ GIT_CONFIG_KEY_0: 'credential.helper',
125
+ GIT_CONFIG_VALUE_0: '',
126
+ });
127
+ }
128
+ finally {
129
+ await rm(dir, { recursive: true, force: true });
130
+ }
131
+ }
132
+ /**
133
+ * Clone with a clean URL + PAT via askpass. Branch/tag refs use `--branch`;
134
+ * commit SHAs clone default HEAD then `git checkout <sha>`.
135
+ */
136
+ export async function runGitClone({ cloneUrl, destDir, ref, pat, timeoutMs, }) {
137
+ let cleanUrl = cloneUrl;
138
+ try {
139
+ const parsed = new URL(cloneUrl);
140
+ if (parsed.username || parsed.password) {
141
+ parsed.username = '';
142
+ parsed.password = '';
143
+ cleanUrl = parsed.toString();
144
+ }
145
+ }
146
+ catch {
147
+ // leave as-is; git will fail loudly
148
+ }
149
+ // Global `-c` config must precede the subcommand. Low-speed settings bound
150
+ // the fetch so a stalled transport aborts fast rather than wedging the deploy.
151
+ const lowSpeed = [
152
+ '-c',
153
+ `http.lowSpeedLimit=${GIT_LOW_SPEED_LIMIT_BYTES}`,
154
+ '-c',
155
+ `http.lowSpeedTime=${GIT_LOW_SPEED_TIME_SEC}`,
156
+ ];
157
+ await withGitAskpass({
158
+ pat,
159
+ fn: async (env) => {
160
+ const args = [...lowSpeed, 'clone'];
161
+ const shaRef = ref && looksLikeCommitSha(ref) ? ref : undefined;
162
+ const branchRef = ref && !shaRef ? ref : undefined;
163
+ if (branchRef)
164
+ args.push('--branch', branchRef);
165
+ args.push(cleanUrl, destDir);
166
+ try {
167
+ await spawnGit({ args, env, ...(timeoutMs ? { timeoutMs } : {}) });
168
+ }
169
+ catch (err) {
170
+ const message = err instanceof Error ? err.message : String(err);
171
+ throw new Error(`${message}\n url: ${redactCloneUrl(cleanUrl)}`);
172
+ }
173
+ if (shaRef) {
174
+ await spawnGit({ args: ['checkout', '--detach', shaRef], env, cwd: destDir, ...(timeoutMs ? { timeoutMs } : {}) });
175
+ }
176
+ },
177
+ });
178
+ }
@@ -0,0 +1,32 @@
1
+ /** Resolve `namespace/name` to a repo id + default branch (FGA-filtered list, paginated). */
2
+ export declare function resolveRepo({ baseUrl, token, namespace, name, }: {
3
+ baseUrl: string;
4
+ token: string;
5
+ namespace: string;
6
+ name: string;
7
+ }): Promise<{
8
+ id: string;
9
+ defaultBranch: string;
10
+ }>;
11
+ /**
12
+ * Resolve a ref (branch, tag, short name, or 40-hex SHA) to its commit SHA.
13
+ * Tries the refs endpoint first (cheap), then commit detail (which also
14
+ * verifies a raw SHA points at a real commit).
15
+ */
16
+ export declare function resolveCommitSha({ baseUrl, token, repoId, ref, }: {
17
+ baseUrl: string;
18
+ token: string;
19
+ repoId: string;
20
+ ref: string;
21
+ }): Promise<string>;
22
+ /**
23
+ * Materialize the full tree at `ref` into `destDir` (recursive tree listing +
24
+ * one content fetch per blob). Returns the number of files written.
25
+ */
26
+ export declare function materializeRepo({ baseUrl, token, repoId, ref, destDir, }: {
27
+ baseUrl: string;
28
+ token: string;
29
+ repoId: string;
30
+ ref: string;
31
+ destDir: string;
32
+ }): Promise<number>;