@kici-dev/compiler 0.1.3 → 0.1.5

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.
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Materialize an isolated tmp checkout of the repo for `kici run local`.
3
+ *
4
+ * Reproduces the same workspace `kici run remote` reconstructs on the agent:
5
+ * a clone checked out at HEAD, with the local overlay (dirty + untracked,
6
+ * minus gitignored, with `.kiciignore` applied) copied on top and local
7
+ * deletions removed. Steps then execute against this copy so the developer's
8
+ * real working tree is never mutated.
9
+ *
10
+ * Secrets are NOT part of the overlay: gitignored files such as
11
+ * `.kici/.env.local` are excluded by the shared selection, so the tmp checkout
12
+ * never receives them — the run reads them from the original `.kici/`.
13
+ */
14
+ /**
15
+ * A materialized isolated checkout.
16
+ */
17
+ export interface MaterializedCheckout {
18
+ /** Absolute path to the tmp checkout directory */
19
+ path: string;
20
+ /** Remove the tmp checkout directory */
21
+ cleanup: () => Promise<void>;
22
+ }
23
+ /**
24
+ * Options for {@link materializeCheckout}.
25
+ */
26
+ export interface MaterializeOptions {
27
+ /** Base directory for the tmp checkout (default: os.tmpdir()) */
28
+ runDir?: string;
29
+ }
30
+ /**
31
+ * Materialize the repo at `repoRoot` into an isolated tmp checkout.
32
+ *
33
+ * @param repoRoot - Path to the git repository root
34
+ * @param opts - Optional configuration (tmp base directory)
35
+ * @returns The tmp checkout path and a cleanup callback
36
+ * @throws If `repoRoot` is not inside a git work tree
37
+ */
38
+ export declare function materializeCheckout(repoRoot: string, opts?: MaterializeOptions): Promise<MaterializedCheckout>;
39
+ //# sourceMappingURL=materializer.d.ts.map
@@ -0,0 +1,111 @@
1
+ import "../chunk-gOLHoazu.js";
2
+ import { selectOverlayFiles } from "../remote/uploader.js";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { execSync } from "node:child_process";
6
+ import os from "node:os";
7
+ import { randomBytes } from "node:crypto";
8
+ //#region src/local-executor/materializer.ts
9
+ /**
10
+ * Materialize an isolated tmp checkout of the repo for `kici run local`.
11
+ *
12
+ * Reproduces the same workspace `kici run remote` reconstructs on the agent:
13
+ * a clone checked out at HEAD, with the local overlay (dirty + untracked,
14
+ * minus gitignored, with `.kiciignore` applied) copied on top and local
15
+ * deletions removed. Steps then execute against this copy so the developer's
16
+ * real working tree is never mutated.
17
+ *
18
+ * Secrets are NOT part of the overlay: gitignored files such as
19
+ * `.kici/.env.local` are excluded by the shared selection, so the tmp checkout
20
+ * never receives them — the run reads them from the original `.kici/`.
21
+ */
22
+ /** Max number of overlay files copied concurrently. */
23
+ const COPY_BATCH_SIZE = 32;
24
+ /**
25
+ * Materialize the repo at `repoRoot` into an isolated tmp checkout.
26
+ *
27
+ * @param repoRoot - Path to the git repository root
28
+ * @param opts - Optional configuration (tmp base directory)
29
+ * @returns The tmp checkout path and a cleanup callback
30
+ * @throws If `repoRoot` is not inside a git work tree
31
+ */
32
+ async function materializeCheckout(repoRoot, opts) {
33
+ requireGitRepo(repoRoot);
34
+ const base = opts?.runDir ?? os.tmpdir();
35
+ await fs.mkdir(base, { recursive: true });
36
+ const tmpDir = path.join(base, `kici-run-${randomBytes(3).toString("hex")}`);
37
+ const { sha, existingFiles, deletedFiles } = await selectOverlayFiles(repoRoot);
38
+ execSync(`git clone --no-hardlinks --quiet ${shellQuote(repoRoot)} ${shellQuote(tmpDir)}`, { stdio: "ignore" });
39
+ execSync(`git checkout --quiet ${sha}`, {
40
+ cwd: tmpDir,
41
+ stdio: "ignore"
42
+ });
43
+ await applyOverlay(repoRoot, tmpDir, existingFiles, deletedFiles);
44
+ return {
45
+ path: tmpDir,
46
+ cleanup: async () => {
47
+ await fs.rm(tmpDir, {
48
+ recursive: true,
49
+ force: true
50
+ });
51
+ }
52
+ };
53
+ }
54
+ /**
55
+ * Throw an actionable error if `repoRoot` is not a git work tree.
56
+ */
57
+ function requireGitRepo(repoRoot) {
58
+ try {
59
+ execSync("git rev-parse --is-inside-work-tree", {
60
+ cwd: repoRoot,
61
+ stdio: "ignore"
62
+ });
63
+ } catch {
64
+ throw new Error(`kici run local needs a git repository to build an isolated checkout, but "${repoRoot}" is not inside a git work tree. Initialize a repo, or re-run with --in-place to execute against the working directory directly.`);
65
+ }
66
+ }
67
+ /**
68
+ * Copy the overlay files onto the clone and remove local deletions.
69
+ */
70
+ async function applyOverlay(repoRoot, tmpDir, existingFiles, deletedFiles) {
71
+ for (let i = 0; i < existingFiles.length; i += COPY_BATCH_SIZE) {
72
+ const batch = existingFiles.slice(i, i + COPY_BATCH_SIZE);
73
+ await Promise.all(batch.map((file) => copyOverlayFile(repoRoot, tmpDir, file)));
74
+ }
75
+ for (let i = 0; i < deletedFiles.length; i += COPY_BATCH_SIZE) {
76
+ const batch = deletedFiles.slice(i, i + COPY_BATCH_SIZE);
77
+ await Promise.all(batch.map((file) => fs.rm(path.join(tmpDir, file), { force: true })));
78
+ }
79
+ }
80
+ /**
81
+ * Copy a single overlay file, preserving its mode (e.g. the exec bit).
82
+ *
83
+ * Symlinks are recreated as links rather than dereferenced — the same shape the
84
+ * remote path's tarball preserves. Following them would copy the link target's
85
+ * content (and a directory symlink such as `node_modules/@scope/pkg` would
86
+ * throw `EISDIR`), losing the link identity the workspace relies on.
87
+ */
88
+ async function copyOverlayFile(repoRoot, tmpDir, file) {
89
+ const src = path.join(repoRoot, file);
90
+ const dest = path.join(tmpDir, file);
91
+ await fs.mkdir(path.dirname(dest), { recursive: true });
92
+ const srcStat = await fs.lstat(src);
93
+ if (srcStat.isSymbolicLink()) {
94
+ const target = await fs.readlink(src);
95
+ await fs.rm(dest, { force: true });
96
+ await fs.symlink(target, dest);
97
+ return;
98
+ }
99
+ await fs.copyFile(src, dest);
100
+ await fs.chmod(dest, srcStat.mode);
101
+ }
102
+ /**
103
+ * Minimal single-quote shell escaping for paths passed to git via execSync.
104
+ */
105
+ function shellQuote(value) {
106
+ return `'${value.replace(/'/g, `'\\''`)}'`;
107
+ }
108
+ //#endregion
109
+ export { materializeCheckout };
110
+
111
+ //# sourceMappingURL=materializer.js.map
@@ -38,6 +38,10 @@ export interface RunLocalOptions {
38
38
  debug?: boolean;
39
39
  /** --kici-dir: path to .kici directory */
40
40
  kiciDir?: string;
41
+ /** --in-place: run against the real working directory instead of an isolated tmp checkout */
42
+ inPlace?: boolean;
43
+ /** --keep: always retain the isolated tmp checkout (default: keep only on failure) */
44
+ keep?: boolean;
41
45
  }
42
46
  /**
43
47
  * A job after matrix expansion with resolved values.
@@ -4,8 +4,6 @@ interface PkceFlowOptions {
4
4
  issuer: string;
5
5
  /** OAuth client ID for the CLI application */
6
6
  clientId: string;
7
- /** OIDC project ID (adds audience scope for JWT access tokens) */
8
- projectId?: string;
9
7
  }
10
8
  /** Options for the RFC 8628 device authorization flow. */
11
9
  interface DeviceFlowOptions {
@@ -13,8 +11,6 @@ interface DeviceFlowOptions {
13
11
  issuer: string;
14
12
  /** OAuth client ID for the CLI application */
15
13
  clientId: string;
16
- /** OIDC project ID (adds audience scope for JWT access tokens) */
17
- projectId?: string;
18
14
  }
19
15
  /** Options for exchanging an OIDC token for a PAT. */
20
16
  interface ExchangeTokenOptions {
@@ -38,8 +34,11 @@ interface ExchangeTokenResult {
38
34
  * PKCE authorization code flow with localhost callback.
39
35
  *
40
36
  * Spins up a temporary HTTP server on a random port, opens the browser
41
- * to the ZITADEL authorization endpoint, captures the callback with the
42
- * authorization code, and exchanges it for tokens.
37
+ * to the IdP's authorization endpoint, captures the callback with the
38
+ * authorization code, and exchanges it for tokens. The authorization
39
+ * and token endpoints come from the IdP's OIDC discovery document
40
+ * (`/.well-known/openid-configuration`) so the same flow works against
41
+ * any spec-compliant IdP regardless of its URL conventions.
43
42
  *
44
43
  * @returns OIDC access token
45
44
  */
@@ -47,9 +46,11 @@ export declare function pkceFlow(opts: PkceFlowOptions): Promise<string>;
47
46
  /**
48
47
  * RFC 8628 device authorization flow.
49
48
  *
50
- * Requests a device code from ZITADEL, displays the user code and
49
+ * Requests a device code from the IdP, displays the user code and
51
50
  * verification URI, then polls the token endpoint until the user
52
- * authorizes the device or the code expires.
51
+ * authorizes the device or the code expires. Endpoints come from the
52
+ * OIDC discovery document so the same flow works against any
53
+ * spec-compliant IdP.
53
54
  *
54
55
  * @returns OIDC access token
55
56
  */
@@ -1,4 +1,5 @@
1
1
  import "../chunk-gOLHoazu.js";
2
+ import { discoverOidcEndpoints } from "./oidc-discovery.js";
2
3
  import pc from "picocolors";
3
4
  import { exec } from "node:child_process";
4
5
  import { toErrorMessage } from "@kici-dev/shared";
@@ -80,15 +81,16 @@ const SUCCESS_HTML = `<!DOCTYPE html>
80
81
  * PKCE authorization code flow with localhost callback.
81
82
  *
82
83
  * Spins up a temporary HTTP server on a random port, opens the browser
83
- * to the ZITADEL authorization endpoint, captures the callback with the
84
- * authorization code, and exchanges it for tokens.
84
+ * to the IdP's authorization endpoint, captures the callback with the
85
+ * authorization code, and exchanges it for tokens. The authorization
86
+ * and token endpoints come from the IdP's OIDC discovery document
87
+ * (`/.well-known/openid-configuration`) so the same flow works against
88
+ * any spec-compliant IdP regardless of its URL conventions.
85
89
  *
86
90
  * @returns OIDC access token
87
91
  */
88
92
  async function pkceFlow(opts) {
89
- const { issuer, clientId, projectId } = opts;
90
- const { verifier, challenge } = generatePkceChallenge();
91
- const state = randomBytes(16).toString("hex");
93
+ const { issuer, clientId } = opts;
92
94
  let listenPort = 0;
93
95
  const callbackPortEnv = process.env.KICI_CALLBACK_PORT;
94
96
  if (callbackPortEnv) {
@@ -96,6 +98,9 @@ async function pkceFlow(opts) {
96
98
  if (isNaN(parsed) || parsed < 0) throw new Error(`KICI_CALLBACK_PORT must be a valid non-negative integer, got: "${callbackPortEnv}"`);
97
99
  listenPort = parsed;
98
100
  }
101
+ const endpoints = await discoverOidcEndpoints(issuer);
102
+ const { verifier, challenge } = generatePkceChallenge();
103
+ const state = randomBytes(16).toString("hex");
99
104
  const TIMEOUT_MS = 300 * 1e3;
100
105
  return new Promise((resolve, reject) => {
101
106
  let timer;
@@ -135,7 +140,7 @@ async function pkceFlow(opts) {
135
140
  res.end(SUCCESS_HTML);
136
141
  const port = server.address().port;
137
142
  try {
138
- const tokenRes = await fetchOrThrow(`${issuer}/oauth/v2/token`, {
143
+ const tokenRes = await fetchOrThrow(endpoints.token_endpoint, {
139
144
  method: "POST",
140
145
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
141
146
  body: new URLSearchParams({
@@ -145,7 +150,7 @@ async function pkceFlow(opts) {
145
150
  redirect_uri: `http://127.0.0.1:${port}/callback`,
146
151
  code_verifier: verifier
147
152
  })
148
- }, "Zitadel");
153
+ }, "IdP");
149
154
  if (!tokenRes.ok) {
150
155
  const errorBody = await tokenRes.text();
151
156
  cleanup();
@@ -167,18 +172,16 @@ async function pkceFlow(opts) {
167
172
  server.listen(listenPort, "127.0.0.1", () => {
168
173
  const actualPort = server.address().port;
169
174
  const redirectUri = `http://127.0.0.1:${actualPort}/callback`;
170
- const authUrl = new URL(`${issuer}/oauth/v2/authorize`);
175
+ const authUrl = new URL(endpoints.authorization_endpoint);
171
176
  authUrl.searchParams.set("client_id", clientId);
172
177
  authUrl.searchParams.set("redirect_uri", redirectUri);
173
178
  authUrl.searchParams.set("response_type", "code");
174
- const scopes = [
179
+ authUrl.searchParams.set("scope", [
175
180
  "openid",
176
181
  "profile",
177
182
  "email",
178
183
  "offline_access"
179
- ];
180
- if (projectId) scopes.push(`urn:zitadel:iam:org:project:id:${projectId}:aud`);
181
- authUrl.searchParams.set("scope", scopes.join(" "));
184
+ ].join(" "));
182
185
  authUrl.searchParams.set("code_challenge", challenge);
183
186
  authUrl.searchParams.set("code_challenge_method", "S256");
184
187
  authUrl.searchParams.set("state", state);
@@ -209,29 +212,34 @@ function sleep(ms) {
209
212
  /**
210
213
  * RFC 8628 device authorization flow.
211
214
  *
212
- * Requests a device code from ZITADEL, displays the user code and
215
+ * Requests a device code from the IdP, displays the user code and
213
216
  * verification URI, then polls the token endpoint until the user
214
- * authorizes the device or the code expires.
217
+ * authorizes the device or the code expires. Endpoints come from the
218
+ * OIDC discovery document so the same flow works against any
219
+ * spec-compliant IdP.
215
220
  *
216
221
  * @returns OIDC access token
217
222
  */
218
223
  async function deviceFlow(opts) {
219
- const { issuer, clientId, projectId } = opts;
224
+ const { issuer, clientId } = opts;
225
+ const endpoints = await discoverOidcEndpoints(issuer);
220
226
  const scopes = [
221
227
  "openid",
222
228
  "profile",
223
229
  "email",
224
230
  "offline_access"
225
231
  ];
226
- if (projectId) scopes.push(`urn:zitadel:iam:org:project:id:${projectId}:aud`);
227
- const deviceAuthRes = await fetchOrThrow(`${issuer}/oauth/v2/device_authorization`, {
232
+ const { verifier, challenge } = generatePkceChallenge();
233
+ const deviceAuthRes = await fetchOrThrow(endpoints.device_authorization_endpoint, {
228
234
  method: "POST",
229
235
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
230
236
  body: new URLSearchParams({
231
237
  client_id: clientId,
232
- scope: scopes.join(" ")
238
+ scope: scopes.join(" "),
239
+ code_challenge: challenge,
240
+ code_challenge_method: "S256"
233
241
  })
234
- }, "Zitadel");
242
+ }, "IdP");
235
243
  if (!deviceAuthRes.ok) {
236
244
  const errorBody = await deviceAuthRes.text();
237
245
  throw new Error(`Device authorization request failed: ${errorBody}`);
@@ -241,7 +249,7 @@ async function deviceFlow(opts) {
241
249
  console.log(pc.bold(" Device authorization"));
242
250
  if (deviceAuth.verification_uri_complete) {
243
251
  console.log(` Open ${pc.cyan(deviceAuth.verification_uri_complete)}`);
244
- console.log(pc.gray(" The code is pre-filled; approve on the Zitadel screen."));
252
+ console.log(pc.gray(" The code is pre-filled; approve on the IdP screen."));
245
253
  console.log(pc.gray(` Enter code: ${pc.bold(pc.yellow(deviceAuth.user_code))} (if prompted)`));
246
254
  } else {
247
255
  console.log(` Open ${pc.cyan(deviceAuth.verification_uri)}`);
@@ -252,15 +260,16 @@ async function deviceFlow(opts) {
252
260
  let intervalMs = (deviceAuth.interval ?? 5) * 1e3;
253
261
  while (true) {
254
262
  await sleep(intervalMs);
255
- const tokenRes = await fetchOrThrow(`${issuer}/oauth/v2/token`, {
263
+ const tokenRes = await fetchOrThrow(endpoints.token_endpoint, {
256
264
  method: "POST",
257
265
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
258
266
  body: new URLSearchParams({
259
267
  client_id: clientId,
260
268
  grant_type: "urn:ietf:params:oauth:grant-type:device_code",
261
- device_code: deviceAuth.device_code
269
+ device_code: deviceAuth.device_code,
270
+ code_verifier: verifier
262
271
  })
263
- }, "Zitadel");
272
+ }, "IdP");
264
273
  if (tokenRes.ok) return (await tokenRes.json()).access_token;
265
274
  const errorData = await tokenRes.json();
266
275
  switch (errorData.error) {
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Endpoints the CLI's OAuth flows need from an OIDC IdP. A subset of the
3
+ * full OIDC Provider Metadata schema (RFC 8414 / OpenID Connect Discovery
4
+ * 1.0) — we only consume what `pkceFlow` / `deviceFlow` actually call.
5
+ */
6
+ export interface OidcDiscoveryEndpoints {
7
+ /** Issuer claim — used for sanity-checking the discovery document. */
8
+ issuer: string;
9
+ /** RFC 6749 authorization endpoint (PKCE). */
10
+ authorization_endpoint: string;
11
+ /** RFC 6749 token endpoint (PKCE code exchange + device-flow polling). */
12
+ token_endpoint: string;
13
+ /** RFC 8628 device authorization endpoint. */
14
+ device_authorization_endpoint: string;
15
+ }
16
+ /**
17
+ * Test-only escape hatch. Clears the per-process cache so a subsequent
18
+ * `discoverOidcEndpoints` call re-fetches the metadata document.
19
+ */
20
+ export declare function resetDiscoveryCache(): void;
21
+ /**
22
+ * Fetch and parse the OIDC Provider Metadata document for an issuer URL,
23
+ * then return the endpoints the CLI needs. Cached per-process so a `kici
24
+ * login` invocation makes at most one discovery round-trip.
25
+ *
26
+ * The issuer is treated as a base URL — trailing slashes are stripped and
27
+ * `/.well-known/openid-configuration` is appended. This works for the two
28
+ * IdP shapes the CLI authenticates against:
29
+ *
30
+ * - Keycloak: issuer = `https://auth.example.com/realms/<name>` (path-segment).
31
+ * - Keycloak: issuer = `https://auth.example.com/realms/<realm>`
32
+ * (realm-scoped — discovery lives at `<issuer>/.well-known/...`).
33
+ *
34
+ * Throws with an actionable message if discovery is unreachable (DNS,
35
+ * connection refused, TLS handshake), returns a non-2xx status, fails to
36
+ * parse as JSON, or omits any of the four required fields.
37
+ */
38
+ export declare function discoverOidcEndpoints(issuer: string): Promise<OidcDiscoveryEndpoints>;
39
+ //# sourceMappingURL=oidc-discovery.d.ts.map
@@ -0,0 +1,72 @@
1
+ import "../chunk-gOLHoazu.js";
2
+ import { toErrorMessage } from "@kici-dev/shared";
3
+ //#region src/remote/oidc-discovery.ts
4
+ const cache = /* @__PURE__ */ new Map();
5
+ /**
6
+ * Test-only escape hatch. Clears the per-process cache so a subsequent
7
+ * `discoverOidcEndpoints` call re-fetches the metadata document.
8
+ */
9
+ function resetDiscoveryCache() {
10
+ cache.clear();
11
+ }
12
+ /**
13
+ * Fetch and parse the OIDC Provider Metadata document for an issuer URL,
14
+ * then return the endpoints the CLI needs. Cached per-process so a `kici
15
+ * login` invocation makes at most one discovery round-trip.
16
+ *
17
+ * The issuer is treated as a base URL — trailing slashes are stripped and
18
+ * `/.well-known/openid-configuration` is appended. This works for the two
19
+ * IdP shapes the CLI authenticates against:
20
+ *
21
+ * - Keycloak: issuer = `https://auth.example.com/realms/<name>` (path-segment).
22
+ * - Keycloak: issuer = `https://auth.example.com/realms/<realm>`
23
+ * (realm-scoped — discovery lives at `<issuer>/.well-known/...`).
24
+ *
25
+ * Throws with an actionable message if discovery is unreachable (DNS,
26
+ * connection refused, TLS handshake), returns a non-2xx status, fails to
27
+ * parse as JSON, or omits any of the four required fields.
28
+ */
29
+ async function discoverOidcEndpoints(issuer) {
30
+ const base = issuer.trim().replace(/\/+$/, "");
31
+ if (!base) throw new Error("[oidc-discovery] issuer is empty");
32
+ const cached = cache.get(base);
33
+ if (cached) return cached;
34
+ const url = `${base}/.well-known/openid-configuration`;
35
+ let res;
36
+ try {
37
+ res = await fetch(url, { method: "GET" });
38
+ } catch (err) {
39
+ throw new Error(`Could not reach IdP discovery at ${url} (${toErrorMessage(err)}). Check KICI_OIDC_ISSUER.`);
40
+ }
41
+ if (!res.ok) throw new Error(`IdP discovery at ${url} returned HTTP ${res.status} ${res.statusText}`);
42
+ let body;
43
+ try {
44
+ body = await res.json();
45
+ } catch (err) {
46
+ throw new Error(`IdP discovery at ${url} returned non-JSON body: ${toErrorMessage(err)}`);
47
+ }
48
+ const parsed = parseEndpoints(body, url);
49
+ cache.set(base, parsed);
50
+ return parsed;
51
+ }
52
+ function parseEndpoints(body, url) {
53
+ if (!body || typeof body !== "object") throw new Error(`IdP discovery at ${url} returned a non-object payload.`);
54
+ const obj = body;
55
+ const missing = [
56
+ "issuer",
57
+ "authorization_endpoint",
58
+ "token_endpoint",
59
+ "device_authorization_endpoint"
60
+ ].filter((k) => typeof obj[k] !== "string" || !obj[k]);
61
+ if (missing.length > 0) throw new Error(`IdP discovery at ${url} is missing required field(s): ${missing.join(", ")}. The OIDC IdP must advertise authorization_endpoint, token_endpoint, and device_authorization_endpoint via /.well-known/openid-configuration.`);
62
+ return {
63
+ issuer: String(obj.issuer),
64
+ authorization_endpoint: String(obj.authorization_endpoint),
65
+ token_endpoint: String(obj.token_endpoint),
66
+ device_authorization_endpoint: String(obj.device_authorization_endpoint)
67
+ };
68
+ }
69
+ //#endregion
70
+ export { discoverOidcEndpoints, resetDiscoveryCache };
71
+
72
+ //# sourceMappingURL=oidc-discovery.js.map
@@ -51,6 +51,41 @@ interface UploadResult {
51
51
  /** Encrypted tarball size in bytes */
52
52
  encryptedSize: number;
53
53
  }
54
+ /**
55
+ * Result of selecting which files form the overlay over a clone at HEAD.
56
+ *
57
+ * Both the remote uploader and the local materializer consume this so the two
58
+ * paths reconstruct the same workspace from the same selection logic.
59
+ */
60
+ export interface OverlaySelection {
61
+ /** HEAD SHA the selection is based on */
62
+ sha: string;
63
+ /** Whether the repo has at least one git remote */
64
+ hasRemote: boolean;
65
+ /** Selected files that exist on disk (to copy onto the clone) */
66
+ existingFiles: string[];
67
+ /** Selected files missing on disk (to delete from the clone) */
68
+ deletedFiles: string[];
69
+ }
70
+ /**
71
+ * Select which files form the overlay over a clone checked out at HEAD.
72
+ *
73
+ * For repos with a remote: collects only dirty files (staged, unstaged, untracked).
74
+ * For repos without a remote: collects ALL tracked + untracked files.
75
+ *
76
+ * The selected set is filtered by `.kiciignore` (picomatch) and partitioned
77
+ * into files that still exist on disk (to copy onto the clone) and files that
78
+ * are missing (to delete from the clone). Gitignored files — including secret
79
+ * files like `.kici/.env.local` — are excluded by `--exclude-standard` and
80
+ * never appear in the selection.
81
+ *
82
+ * @param repoRoot - Path to the git repository root
83
+ * @param options - Optional configuration
84
+ * @returns HEAD SHA, remote flag, and the existing/deleted file partition
85
+ */
86
+ export declare function selectOverlayFiles(repoRoot: string, options?: {
87
+ kiciIgnorePath?: string;
88
+ }): Promise<OverlaySelection>;
54
89
  /**
55
90
  * Create an overlay tarball from a git repo, including only changed files.
56
91
  *
@@ -45,19 +45,22 @@ async function loadKiciIgnore(kiciIgnorePath) {
45
45
  }
46
46
  }
47
47
  /**
48
- * Create an overlay tarball from a git repo, including only changed files.
48
+ * Select which files form the overlay over a clone checked out at HEAD.
49
49
  *
50
50
  * For repos with a remote: collects only dirty files (staged, unstaged, untracked).
51
- * For repos without a remote: collects ALL tracked + untracked files (full tarball).
51
+ * For repos without a remote: collects ALL tracked + untracked files.
52
52
  *
53
- * The tarball includes a `manifest.json` with the HEAD SHA, deletions list,
54
- * and SHA256 checksums for integrity verification.
53
+ * The selected set is filtered by `.kiciignore` (picomatch) and partitioned
54
+ * into files that still exist on disk (to copy onto the clone) and files that
55
+ * are missing (to delete from the clone). Gitignored files — including secret
56
+ * files like `.kici/.env.local` — are excluded by `--exclude-standard` and
57
+ * never appear in the selection.
55
58
  *
56
59
  * @param repoRoot - Path to the git repository root
57
60
  * @param options - Optional configuration
58
- * @returns Tarball path, upload summary, and overlay manifest
61
+ * @returns HEAD SHA, remote flag, and the existing/deleted file partition
59
62
  */
60
- async function createOverlayTarball(repoRoot, options) {
63
+ async function selectOverlayFiles(repoRoot, options) {
61
64
  const sha = execSync("git rev-parse HEAD", {
62
65
  cwd: repoRoot,
63
66
  encoding: "utf-8"
@@ -91,6 +94,28 @@ async function createOverlayTarball(repoRoot, options) {
91
94
  deletedFiles.push(file);
92
95
  }
93
96
  }));
97
+ return {
98
+ sha,
99
+ hasRemote,
100
+ existingFiles,
101
+ deletedFiles
102
+ };
103
+ }
104
+ /**
105
+ * Create an overlay tarball from a git repo, including only changed files.
106
+ *
107
+ * For repos with a remote: collects only dirty files (staged, unstaged, untracked).
108
+ * For repos without a remote: collects ALL tracked + untracked files (full tarball).
109
+ *
110
+ * The tarball includes a `manifest.json` with the HEAD SHA, deletions list,
111
+ * and SHA256 checksums for integrity verification.
112
+ *
113
+ * @param repoRoot - Path to the git repository root
114
+ * @param options - Optional configuration
115
+ * @returns Tarball path, upload summary, and overlay manifest
116
+ */
117
+ async function createOverlayTarball(repoRoot, options) {
118
+ const { sha, hasRemote, existingFiles, deletedFiles } = await selectOverlayFiles(repoRoot, options);
94
119
  const untrackedSet = new Set(gitLines("git ls-files --others --exclude-standard", repoRoot));
95
120
  const newFiles = existingFiles.filter((f) => untrackedSet.has(f));
96
121
  const modifiedFiles = existingFiles.filter((f) => !untrackedSet.has(f));
@@ -192,6 +217,6 @@ async function uploadTarball(opts) {
192
217
  throw new Error(`Upload failed after ${MAX_RETRIES} attempts: ${lastError?.message}`);
193
218
  }
194
219
  //#endregion
195
- export { createOverlayTarball, getSizeWarning, uploadTarball };
220
+ export { createOverlayTarball, getSizeWarning, selectOverlayFiles, uploadTarball };
196
221
 
197
222
  //# sourceMappingURL=uploader.js.map
@@ -1,6 +1,6 @@
1
1
  import "../chunk-gOLHoazu.js";
2
2
  //#region src/templates/package-json.ts
3
- const sdkVersion = "0.1.3";
3
+ const sdkVersion = "0.1.5";
4
4
  /**
5
5
  * Generate package.json content for .kici/ directory
6
6
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kici-dev/compiler",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Compiler and CLI for KiCI workflows. Compiles `.kici/workflows/*.ts` to a `kici.lock.json` file consumed by the orchestrator and agents, and runs workflows locally or against a remote orchestrator.",
5
5
  "keywords": [
6
6
  "kici",
@@ -23,7 +23,6 @@
23
23
  "node": ">=24"
24
24
  },
25
25
  "publishConfig": {
26
- "registry": "https://registry.npmjs.org/",
27
26
  "access": "public"
28
27
  },
29
28
  "type": "module",
@@ -59,11 +58,11 @@
59
58
  "ws": "^8.20.0",
60
59
  "yaml": "^2.8.3",
61
60
  "zx": "^8.8.5",
62
- "@kici-dev/engine": "0.1.3",
63
- "@kici-dev/shared": "0.1.3"
61
+ "@kici-dev/engine": "0.1.5",
62
+ "@kici-dev/shared": "0.1.5"
64
63
  },
65
64
  "peerDependencies": {
66
- "@kici-dev/sdk": "0.1.3"
65
+ "@kici-dev/sdk": "0.1.5"
67
66
  },
68
67
  "devDependencies": {
69
68
  "@types/proper-lockfile": "^4.1.4"