@sequenceholdings/artifact-studio 0.1.5 → 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.
- 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 +493 -121
- 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 +178 -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 +21 -0
- package/dist/prepare-build.js +94 -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 +202 -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
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-
|
|
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 {};
|
package/dist/dev-link.js
ADDED
|
@@ -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
|
+
}
|
package/dist/dev-lock.js
ADDED
|
@@ -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>;
|