@kryd/cli 0.7.0 → 0.8.1

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 (3) hide show
  1. package/README.md +1 -0
  2. package/dist/index.js +1003 -98
  3. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -14855,6 +14855,10 @@ function validateBuildEnvVarValue(value) {
14855
14855
  var BUILD_ENV_PUBLIC_WARNING = "\u26A0 Build-time values are compiled into your app's public bundle \u2014 anyone who loads your site can read them. Never put a secret here.";
14856
14856
  var BUILD_ENV_REBUILD_NOTE = "This takes effect at your next BUILD, not your next deploy \u2014 the value is compiled into the image, so redeploying the existing image keeps the old value. Push a commit (`kryd push`) to rebuild.";
14857
14857
 
14858
+ // ../../packages/shared-types/dist/workflow-run.js
14859
+ var MAX_WEBHOOK_NAME = 32;
14860
+ var WEBHOOK_NAME_RE = new RegExp(`^[a-z0-9](?:[a-z0-9-]{0,${MAX_WEBHOOK_NAME - 2}}[a-z0-9])?$`);
14861
+
14858
14862
  // ../../packages/shared-types/dist/index.js
14859
14863
  var DEPLOY_STATES = [
14860
14864
  "queued",
@@ -14895,8 +14899,14 @@ function isTerminalResourceStatus(status) {
14895
14899
  }
14896
14900
 
14897
14901
  // src/index.ts
14898
- import { existsSync as existsSync3, realpathSync } from "node:fs";
14899
- import { isAbsolute, relative, resolve } from "node:path";
14902
+ import {
14903
+ existsSync as existsSync3,
14904
+ mkdirSync as mkdirSync2,
14905
+ realpathSync,
14906
+ rmSync as rmSync2,
14907
+ writeFileSync as writeFileSync2
14908
+ } from "node:fs";
14909
+ import { basename as basename2, dirname as dirname2, isAbsolute, join as join3, relative, resolve as resolve2 } from "node:path";
14900
14910
  import { pathToFileURL } from "node:url";
14901
14911
 
14902
14912
  // src/config.ts
@@ -14950,8 +14960,13 @@ function clearToken() {
14950
14960
  delete config2.token;
14951
14961
  writeConfig(config2);
14952
14962
  }
14963
+ var lastApiUrl = null;
14953
14964
  function resolveApiUrl(flag) {
14954
- return flag ?? process.env.KRYD_API_URL ?? loadConfig().apiUrl ?? DEFAULT_API_URL;
14965
+ lastApiUrl = flag ?? process.env.KRYD_API_URL ?? loadConfig().apiUrl ?? DEFAULT_API_URL;
14966
+ return lastApiUrl;
14967
+ }
14968
+ function currentApiUrl() {
14969
+ return lastApiUrl ?? resolveApiUrl();
14955
14970
  }
14956
14971
  var DEFAULT_DASHBOARD_URL = "https://app.kryd.eu";
14957
14972
  function resolveDashboardUrl(flag) {
@@ -15018,6 +15033,11 @@ function ensureIgnored(cwd, entry, aliases = []) {
15018
15033
  function ensureKrydIgnored(cwd) {
15019
15034
  ensureIgnored(cwd, `${PROJECT_LINK_DIR}/`, [PROJECT_LINK_DIR]);
15020
15035
  }
15036
+ function directoryIsLinked(cwd) {
15037
+ const path = join(cwd, PROJECT_LINK_DIR, PROJECT_LINK_FILE);
15038
+ if (!existsSync(path)) return null;
15039
+ return loadProjectLink(cwd);
15040
+ }
15021
15041
  function writeSecretFile(path, contents) {
15022
15042
  mkdirSync(dirname(path), { recursive: true });
15023
15043
  writeFileSync(path, contents, { mode: 384 });
@@ -15035,9 +15055,33 @@ function clearProjectLinkIfMatches(projectId, cwd = process.cwd()) {
15035
15055
  return false;
15036
15056
  }
15037
15057
  }
15058
+ function resolvedLink() {
15059
+ return lastLinkResolution;
15060
+ }
15061
+ var lastLinkResolution = null;
15038
15062
  function resolveProjectId(explicit, cwd = process.cwd()) {
15063
+ lastLinkResolution = null;
15039
15064
  if (explicit) return explicit;
15040
- return loadProjectLink(cwd)?.projectId ?? null;
15065
+ const root = findProjectLinkDir(cwd);
15066
+ if (!root) return null;
15067
+ const link = loadProjectLink(cwd);
15068
+ if (!link) return null;
15069
+ lastLinkResolution = { root, link };
15070
+ return link.projectId;
15071
+ }
15072
+ function rememberLinkAccount(root, account) {
15073
+ if (!account.accountId) return false;
15074
+ const link = directoryIsLinked(root);
15075
+ if (!link) return false;
15076
+ const slug = account.accountSlug ?? link.accountSlug;
15077
+ if (link.accountId === account.accountId && link.accountSlug === slug) return false;
15078
+ saveProjectLink(root, {
15079
+ ...link,
15080
+ accountId: account.accountId,
15081
+ // `exactOptionalPropertyTypes`: an absent key and an explicit `undefined` are different types.
15082
+ ...slug ? { accountSlug: slug } : {}
15083
+ });
15084
+ return true;
15041
15085
  }
15042
15086
 
15043
15087
  // src/browser-login.ts
@@ -15058,7 +15102,7 @@ function browserLogin(dashboardUrl, opts = {}) {
15058
15102
  const state = randomUUID();
15059
15103
  const timeoutMs = opts.timeoutMs ?? 12e4;
15060
15104
  const shouldOpen = opts.open ?? true;
15061
- return new Promise((resolve2, reject) => {
15105
+ return new Promise((resolve3, reject) => {
15062
15106
  let settled = false;
15063
15107
  const settle = (fn) => {
15064
15108
  if (settled) return;
@@ -15090,7 +15134,7 @@ function browserLogin(dashboardUrl, opts = {}) {
15090
15134
  res.end(SUCCESS_HTML, () => {
15091
15135
  settle(() => {
15092
15136
  shutdown();
15093
- resolve2({ token, ...apiUrl ? { apiUrl } : {} });
15137
+ resolve3({ token, ...apiUrl ? { apiUrl } : {} });
15094
15138
  });
15095
15139
  });
15096
15140
  });
@@ -15125,7 +15169,15 @@ Waiting for approval\u2026
15125
15169
  // src/client.ts
15126
15170
  var ApiError = class extends Error {
15127
15171
  envelope;
15128
- /** The HTTP status of the failed response, when it came from one (used to detect a 404 = gone). */
15172
+ /**
15173
+ * The HTTP status of the failed response, when it came from one (used to detect a 404 = gone).
15174
+ *
15175
+ * ⚠️ Still optional because an `ApiError` is also thrown for a well-formed response the CLI
15176
+ * cannot use ("the API returned an unexpected …") and for a stream's own error frame — neither
15177
+ * has a status. Every throw that DOES hold a `Response` now passes it (KRYD-481): the 404
15178
+ * diagnosis keys on the status, and a third of them used to omit it, which made the same
15179
+ * failure explainable or unexplainable depending on which verb the customer typed.
15180
+ */
15129
15181
  status;
15130
15182
  constructor(message, envelope, status) {
15131
15183
  super(message);
@@ -15150,7 +15202,8 @@ async function login(apiUrl, creds) {
15150
15202
  if (!res.ok) {
15151
15203
  throw new ApiError(
15152
15204
  `Login failed (${res.status})`,
15153
- await parseEnvelope(res)
15205
+ await parseEnvelope(res),
15206
+ res.status
15154
15207
  );
15155
15208
  }
15156
15209
  const token = res.headers.get("set-auth-token");
@@ -15166,7 +15219,8 @@ async function whoami(apiUrl, token) {
15166
15219
  if (!res.ok) {
15167
15220
  throw new ApiError(
15168
15221
  `Request failed (${res.status})`,
15169
- await parseEnvelope(res)
15222
+ await parseEnvelope(res),
15223
+ res.status
15170
15224
  );
15171
15225
  }
15172
15226
  return await res.json();
@@ -15183,7 +15237,8 @@ async function linkProject(apiUrl, token, input) {
15183
15237
  if (!res.ok) {
15184
15238
  throw new ApiError(
15185
15239
  `Repo linking failed (${res.status})`,
15186
- await parseEnvelope(res)
15240
+ await parseEnvelope(res),
15241
+ res.status
15187
15242
  );
15188
15243
  }
15189
15244
  const parsed = await res.json().catch(() => void 0);
@@ -15211,7 +15266,8 @@ async function attachDatabase(apiUrl, token, input) {
15211
15266
  if (!res.ok) {
15212
15267
  throw new ApiError(
15213
15268
  `Database attach failed (${res.status})`,
15214
- await parseEnvelope(res)
15269
+ await parseEnvelope(res),
15270
+ res.status
15215
15271
  );
15216
15272
  }
15217
15273
  const parsed = await res.json().catch(() => void 0);
@@ -15236,7 +15292,8 @@ async function createStorage(apiUrl, token, input) {
15236
15292
  if (!res.ok) {
15237
15293
  throw new ApiError(
15238
15294
  `Storage create failed (${res.status})`,
15239
- await parseEnvelope(res)
15295
+ await parseEnvelope(res),
15296
+ res.status
15240
15297
  );
15241
15298
  }
15242
15299
  const parsed = await res.json().catch(() => void 0);
@@ -15438,7 +15495,8 @@ async function detachDatabase(apiUrl, token, projectId) {
15438
15495
  if (!res.ok) {
15439
15496
  throw new ApiError(
15440
15497
  `Database detach failed (${res.status})`,
15441
- await parseEnvelope(res)
15498
+ await parseEnvelope(res),
15499
+ res.status
15442
15500
  );
15443
15501
  }
15444
15502
  }
@@ -15448,7 +15506,7 @@ async function getProject(apiUrl, token, projectId) {
15448
15506
  });
15449
15507
  if (res.status === 404) return null;
15450
15508
  if (!res.ok) {
15451
- throw new ApiError(`Reading the project failed (${res.status})`, await parseEnvelope(res));
15509
+ throw new ApiError(`Reading the project failed (${res.status})`, await parseEnvelope(res), res.status);
15452
15510
  }
15453
15511
  return await res.json();
15454
15512
  }
@@ -15457,7 +15515,7 @@ async function listProjects(apiUrl, token) {
15457
15515
  headers: { authorization: `Bearer ${token}` }
15458
15516
  });
15459
15517
  if (!res.ok) {
15460
- throw new ApiError(`Listing projects failed (${res.status})`, await parseEnvelope(res));
15518
+ throw new ApiError(`Listing projects failed (${res.status})`, await parseEnvelope(res), res.status);
15461
15519
  }
15462
15520
  return (await res.json()).items;
15463
15521
  }
@@ -15467,7 +15525,7 @@ async function deleteProject(apiUrl, token, projectId) {
15467
15525
  headers: { authorization: `Bearer ${token}` }
15468
15526
  });
15469
15527
  if (!res.ok) {
15470
- throw new ApiError(`Project delete failed (${res.status})`, await parseEnvelope(res));
15528
+ throw new ApiError(`Project delete failed (${res.status})`, await parseEnvelope(res), res.status);
15471
15529
  }
15472
15530
  return await res.json();
15473
15531
  }
@@ -15502,7 +15560,8 @@ async function detachStorage(apiUrl, token, projectId) {
15502
15560
  if (!res.ok) {
15503
15561
  throw new ApiError(
15504
15562
  `Storage detach failed (${res.status})`,
15505
- await parseEnvelope(res)
15563
+ await parseEnvelope(res),
15564
+ res.status
15506
15565
  );
15507
15566
  }
15508
15567
  }
@@ -15668,7 +15727,7 @@ async function triggerDeploy(apiUrl, token, projectId) {
15668
15727
  body: JSON.stringify({ projectId })
15669
15728
  });
15670
15729
  if (!res.ok) {
15671
- throw new ApiError(`Deploy trigger failed (${res.status})`, await parseEnvelope(res));
15730
+ throw new ApiError(`Deploy trigger failed (${res.status})`, await parseEnvelope(res), res.status);
15672
15731
  }
15673
15732
  return (await res.json()).deploymentId;
15674
15733
  }
@@ -15685,7 +15744,7 @@ async function rollbackDeploy(apiUrl, token, projectId, deploymentId) {
15685
15744
  })
15686
15745
  });
15687
15746
  if (!res.ok) {
15688
- throw new ApiError(`Rollback failed (${res.status})`, await parseEnvelope(res));
15747
+ throw new ApiError(`Rollback failed (${res.status})`, await parseEnvelope(res), res.status);
15689
15748
  }
15690
15749
  return await res.json();
15691
15750
  }
@@ -15699,7 +15758,7 @@ async function redeployProject(apiUrl, token, projectId) {
15699
15758
  body: JSON.stringify({ projectId })
15700
15759
  });
15701
15760
  if (!res.ok) {
15702
- throw new ApiError(`Redeploy failed (${res.status})`, await parseEnvelope(res));
15761
+ throw new ApiError(`Redeploy failed (${res.status})`, await parseEnvelope(res), res.status);
15703
15762
  }
15704
15763
  return await res.json();
15705
15764
  }
@@ -15708,7 +15767,7 @@ async function listDeployments(apiUrl, token, projectId) {
15708
15767
  if (projectId) url2.searchParams.set("projectId", projectId);
15709
15768
  const res = await fetch(url2, { headers: { authorization: `Bearer ${token}` } });
15710
15769
  if (!res.ok) {
15711
- throw new ApiError(`Listing deploys failed (${res.status})`, await parseEnvelope(res));
15770
+ throw new ApiError(`Listing deploys failed (${res.status})`, await parseEnvelope(res), res.status);
15712
15771
  }
15713
15772
  return (await res.json()).items;
15714
15773
  }
@@ -15764,14 +15823,14 @@ async function* readSseFrames(body) {
15764
15823
  }
15765
15824
  function abortableSleep(ms, signal) {
15766
15825
  if (signal?.aborted) return Promise.resolve();
15767
- return new Promise((resolve2) => {
15826
+ return new Promise((resolve3) => {
15768
15827
  const onAbort = () => {
15769
15828
  clearTimeout(timer);
15770
- resolve2();
15829
+ resolve3();
15771
15830
  };
15772
15831
  const timer = setTimeout(() => {
15773
15832
  signal?.removeEventListener("abort", onAbort);
15774
- resolve2();
15833
+ resolve3();
15775
15834
  }, ms);
15776
15835
  signal?.addEventListener("abort", onAbort, { once: true });
15777
15836
  });
@@ -15787,7 +15846,7 @@ async function streamDeploy(apiUrl, token, deploymentId, handlers, opts = {}) {
15787
15846
  headers: { authorization: `Bearer ${token}`, accept: "text/event-stream" }
15788
15847
  });
15789
15848
  if (!res.ok) {
15790
- throw new ApiError(`Log stream failed (${res.status})`, await parseEnvelope(res));
15849
+ throw new ApiError(`Log stream failed (${res.status})`, await parseEnvelope(res), res.status);
15791
15850
  }
15792
15851
  if (!res.body) throw new Error("The log stream returned no body.");
15793
15852
  for await (const data of readSseFrames(res.body)) {
@@ -15831,7 +15890,8 @@ async function streamRuntime(apiUrl, token, id, handlers, opts = {}) {
15831
15890
  if (!res.ok) {
15832
15891
  throw new ApiError(
15833
15892
  `Runtime log stream failed (${res.status})`,
15834
- await parseEnvelope(res)
15893
+ await parseEnvelope(res),
15894
+ res.status
15835
15895
  );
15836
15896
  }
15837
15897
  if (!res.body) throw new Error("The runtime log stream returned no body.");
@@ -15860,11 +15920,59 @@ async function fetchDeployLog(apiUrl, token, deploymentId, opts = {}) {
15860
15920
  if (!res.ok) {
15861
15921
  throw new ApiError(
15862
15922
  `Fetching the stored log failed (${res.status})`,
15863
- await parseEnvelope(res)
15923
+ await parseEnvelope(res),
15924
+ res.status
15864
15925
  );
15865
15926
  }
15866
15927
  return (await res.json()).log;
15867
15928
  }
15929
+ async function fetchPushCredential(apiUrl, token, projectId) {
15930
+ const res = await fetch(
15931
+ `${apiUrl}/repos/${encodeURIComponent(projectId)}/push-credential`,
15932
+ { headers: { authorization: `Bearer ${token}` } }
15933
+ );
15934
+ if (!res.ok) {
15935
+ throw new ApiError(
15936
+ `Could not fetch the push credential (${res.status})`,
15937
+ await parseEnvelope(res),
15938
+ res.status
15939
+ );
15940
+ }
15941
+ const parsed = await res.json().catch(() => void 0);
15942
+ if (!parsed || typeof parsed.username !== "string" || typeof parsed.password !== "string" || parsed.password.length === 0) {
15943
+ throw new ApiError("The API returned an unexpected push-credential response.");
15944
+ }
15945
+ return { username: parsed.username, password: parsed.password };
15946
+ }
15947
+
15948
+ // src/git-credential.ts
15949
+ async function runGitCredential(operation, opts = {}) {
15950
+ if (operation !== "get") return;
15951
+ const input = parseCredentialInput(opts.stdin ?? "");
15952
+ if (input.protocol && input.protocol !== "https") return;
15953
+ const cwd = opts.cwd ?? process.cwd();
15954
+ const token = loadConfig().token;
15955
+ if (!token) return;
15956
+ const projectId = resolveProjectId(void 0, cwd);
15957
+ if (!projectId) return;
15958
+ try {
15959
+ const cred = await fetchPushCredential(resolveApiUrl(opts.apiUrl), token, projectId);
15960
+ process.stdout.write(`username=${cred.username}
15961
+ password=${cred.password}
15962
+ `);
15963
+ } catch {
15964
+ }
15965
+ }
15966
+ function parseCredentialInput(raw) {
15967
+ const out = {};
15968
+ for (const line of raw.split("\n")) {
15969
+ if (line === "") continue;
15970
+ const eq = line.indexOf("=");
15971
+ if (eq <= 0) continue;
15972
+ out[line.slice(0, eq)] = line.slice(eq + 1);
15973
+ }
15974
+ return out;
15975
+ }
15868
15976
 
15869
15977
  // src/framework.ts
15870
15978
  import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
@@ -15873,11 +15981,14 @@ var FRAMEWORKS = [
15873
15981
  "react-router",
15874
15982
  "nextjs",
15875
15983
  "vite-spa",
15876
- "node"
15984
+ "node",
15985
+ "workflow"
15877
15986
  ];
15878
15987
  function isFramework(value) {
15879
15988
  return FRAMEWORKS.includes(value);
15880
15989
  }
15990
+ var DECLARATION_FILE = "kryd.json";
15991
+ var DECLARATION_KIND_WORKFLOW = "workflow";
15881
15992
  var VITE_META_FRAMEWORKS = [
15882
15993
  "@sveltejs/kit",
15883
15994
  "astro",
@@ -15919,7 +16030,22 @@ function readPackageJson(cwd) {
15919
16030
  return null;
15920
16031
  }
15921
16032
  }
16033
+ function declaredKind(cwd) {
16034
+ try {
16035
+ const parsed = JSON.parse(
16036
+ readFileSync2(join2(cwd, DECLARATION_FILE), "utf8")
16037
+ );
16038
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
16039
+ return null;
16040
+ }
16041
+ const kind = parsed.kind;
16042
+ return typeof kind === "string" ? kind : null;
16043
+ } catch {
16044
+ return null;
16045
+ }
16046
+ }
15922
16047
  function frameworkFrom(pkg, cwd) {
16048
+ if (declaredKind(cwd) === DECLARATION_KIND_WORKFLOW) return "workflow";
15923
16049
  const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
15924
16050
  const has = (name) => name in deps;
15925
16051
  if (has("next")) return "nextjs";
@@ -15941,6 +16067,528 @@ function inspectProject(cwd) {
15941
16067
  return { framework: frameworkFrom(pkg, cwd), name: nameFrom(pkg, cwd) };
15942
16068
  }
15943
16069
 
16070
+ // src/scaffold/typescript.ts
16071
+ var TYPESCRIPT_TEMPLATE = {
16072
+ files: (workerName) => [
16073
+ {
16074
+ path: "package.json",
16075
+ contents: `${JSON.stringify(
16076
+ {
16077
+ name: workerName,
16078
+ private: true,
16079
+ version: "0.1.0",
16080
+ scripts: {
16081
+ build: "tsc",
16082
+ start: "node dist/worker.js"
16083
+ },
16084
+ engines: { node: ">=20" },
16085
+ dependencies: {
16086
+ "@hatchet-dev/typescript-sdk": "^1.33.0"
16087
+ },
16088
+ devDependencies: {
16089
+ "@types/node": "^22.10.2",
16090
+ typescript: "^5.7.2"
16091
+ }
16092
+ },
16093
+ null,
16094
+ 2
16095
+ )}
16096
+ `
16097
+ },
16098
+ {
16099
+ path: "tsconfig.json",
16100
+ contents: `${JSON.stringify(
16101
+ {
16102
+ compilerOptions: {
16103
+ // CommonJS because the SDK is: it ships no "exports" map and no "type": "module".
16104
+ module: "commonjs",
16105
+ moduleResolution: "node",
16106
+ target: "es2022",
16107
+ lib: ["es2022"],
16108
+ outDir: "dist",
16109
+ rootDir: "src",
16110
+ strict: true,
16111
+ esModuleInterop: true,
16112
+ skipLibCheck: true
16113
+ },
16114
+ include: ["src"]
16115
+ },
16116
+ null,
16117
+ 2
16118
+ )}
16119
+ `
16120
+ },
16121
+ {
16122
+ path: "src/hatchet.ts",
16123
+ contents: `import { HatchetClient } from "@hatchet-dev/typescript-sdk/v1";
16124
+
16125
+ /**
16126
+ * Reads HATCHET_CLIENT_TOKEN from the environment and needs nothing else: the token carries the
16127
+ * engine's addresses. Kryd injects it on every deploy once you have run \`kryd workflow add\`.
16128
+ *
16129
+ * It lives in its own file so tasks and the worker can share one client without importing each
16130
+ * other in a circle.
16131
+ */
16132
+ export const hatchet = HatchetClient.init();
16133
+ `
16134
+ },
16135
+ {
16136
+ path: "src/tasks.ts",
16137
+ contents: `import { hatchet } from "./hatchet";
16138
+
16139
+ /**
16140
+ * One task, to prove the wiring end to end. Add your own beside it and list them in
16141
+ * \`src/worker.ts\` \u2014 everything about how a task runs (retries, timeouts, concurrency, crons,
16142
+ * DAGs) belongs to the engine's SDK, not to Kryd.
16143
+ */
16144
+ export const greet = hatchet.task({
16145
+ name: "greet",
16146
+ fn: (input: { name?: string }) => ({
16147
+ greeting: \`Hello, \${input.name ?? "world"}\`,
16148
+ }),
16149
+ });
16150
+ `
16151
+ },
16152
+ {
16153
+ path: "src/worker.ts",
16154
+ contents: `import { createServer } from "node:http";
16155
+
16156
+ const WORKER_NAME = ${JSON.stringify(workerName)};
16157
+
16158
+ /*
16159
+ * The health listener, and the one piece of this file that is about Kryd rather than about workflows.
16160
+ *
16161
+ * A worker holds an outbound gRPC session and listens on no port of its own, but Kryd decides a
16162
+ * deploy is live by asking GET / for a 2xx. Without this the deploy is torn down with
16163
+ * "connection refused (nothing listening yet)".
16164
+ *
16165
+ * \u{1F6A8} It answers 503 until the engine has registered the worker, on purpose. Answering 200 straight
16166
+ * away would mark the deploy live a moment before a failed registration crashes the process \u2014 which
16167
+ * is exactly what happened while this was being measured.
16168
+ */
16169
+ let registered = false;
16170
+
16171
+ createServer((_req, res) => {
16172
+ res.writeHead(registered ? 200 : 503, { "content-type": "application/json" });
16173
+ res.end(
16174
+ JSON.stringify({
16175
+ worker: WORKER_NAME,
16176
+ status: registered ? "registered" : "starting",
16177
+ }),
16178
+ );
16179
+ }).listen(Number(process.env.PORT ?? 8080), "0.0.0.0");
16180
+
16181
+ async function main(): Promise<void> {
16182
+ // Imported here rather than at the top of the file, and that is deliberate: creating the client
16183
+ // reads and decodes HATCHET_CLIENT_TOKEN, and a token it cannot parse makes it throw during
16184
+ // module evaluation \u2014 before the listener above ever binds. The deploy would then fail with
16185
+ // "connection refused (nothing listening yet)", which is the one message this whole scaffold
16186
+ // exists to stop you seeing. Binding first means a bad token shows up as a 503 plus a readable
16187
+ // error in your deploy log.
16188
+ const { hatchet } = await import("./hatchet");
16189
+ const { greet } = await import("./tasks");
16190
+
16191
+ const worker = await hatchet.worker(WORKER_NAME, { workflows: [greet] });
16192
+
16193
+ // start() resolves when the worker stops, so it is not awaited here; waitUntilReady() flips the
16194
+ // health flag once the engine has the worker. A failed registration rejects \`running\` and the
16195
+ // process exits non-zero, which fails the deploy loudly instead of going live and crash-looping.
16196
+ const running = worker.start();
16197
+ void worker.waitUntilReady().then(
16198
+ () => {
16199
+ registered = true;
16200
+ },
16201
+ () => {
16202
+ // Stays 503. The deploy gate reports it; there is nothing useful to do here.
16203
+ },
16204
+ );
16205
+ await running;
16206
+ }
16207
+
16208
+ main().catch((err: unknown) => {
16209
+ console.error("[worker] stopped:", err);
16210
+ process.exit(1);
16211
+ });
16212
+ `
16213
+ },
16214
+ {
16215
+ path: ".gitignore",
16216
+ contents: `node_modules/
16217
+ dist/
16218
+ .env
16219
+ .env.local
16220
+ `
16221
+ },
16222
+ {
16223
+ path: "README.md",
16224
+ contents: `# ${workerName}
16225
+
16226
+ A Hatchet workflow worker, ready to deploy on Kryd.
16227
+
16228
+ - \`src/tasks.ts\` holds your tasks. \`src/worker.ts\` starts the worker and serves the health
16229
+ endpoint Kryd's deploy gate needs \u2014 keep that listener, or your deploys will be torn down.
16230
+ - **\`HATCHET_CLIENT_TOKEN\` only exists inside a deployed container.** Kryd never prints it, so you
16231
+ cannot run this worker against your tenant from your laptop today.
16232
+ - Deploy it: \`kryd workflow add\` once, then \`kryd push\`.
16233
+
16234
+ Full docs: https://docs.kryd.eu/docs/workflows
16235
+ `
16236
+ }
16237
+ ]
16238
+ };
16239
+
16240
+ // src/scaffold/python.ts
16241
+ var PYTHON_TEMPLATE = {
16242
+ files: (workerName) => [
16243
+ {
16244
+ path: "requirements.txt",
16245
+ contents: `hatchet-sdk>=1.40.1
16246
+ `
16247
+ },
16248
+ {
16249
+ path: "tasks.py",
16250
+ contents: `from hatchet_client import hatchet
16251
+ from hatchet_sdk import Context, EmptyModel
16252
+
16253
+
16254
+ # One task, to prove the wiring end to end. Add your own beside it and list them in main.py \u2014
16255
+ # everything about how a task runs (retries, timeouts, concurrency, crons, DAGs) belongs to the
16256
+ # engine's SDK, not to Kryd.
16257
+ @hatchet.task(name="greet")
16258
+ def greet(input: EmptyModel, ctx: Context) -> dict[str, str]:
16259
+ return {"greeting": "Hello, world"}
16260
+ `
16261
+ },
16262
+ {
16263
+ path: "hatchet_client.py",
16264
+ contents: `from hatchet_sdk import Hatchet
16265
+
16266
+ # Reads HATCHET_CLIENT_TOKEN from the environment and needs nothing else: the token carries the
16267
+ # engine's addresses. Kryd injects it on every deploy once you have run \`kryd workflow add\`.
16268
+ #
16269
+ # It lives in its own module so tasks and the worker can share one client without importing each
16270
+ # other in a circle.
16271
+ hatchet = Hatchet()
16272
+ `
16273
+ },
16274
+ {
16275
+ path: "main.py",
16276
+ contents: `"""Worker entry point.
16277
+
16278
+ \u{1F6A8} This file must stay at the project root and keep this name: Kryd's builder derives the start
16279
+ command by finding main.py here. Move it into a package and the deploy has no start command at all.
16280
+ """
16281
+
16282
+ import json
16283
+ import os
16284
+ import threading
16285
+ import time
16286
+ from http.server import BaseHTTPRequestHandler, HTTPServer
16287
+
16288
+ WORKER_NAME = ${JSON.stringify(workerName)}
16289
+
16290
+ # The health listener, and the one piece of this file that is about Kryd rather than about workflows.
16291
+ #
16292
+ # A worker holds an outbound gRPC session and listens on no port of its own, but Kryd decides a
16293
+ # deploy is live by asking GET / for a 2xx. Without this the deploy is torn down with
16294
+ # "connection refused (nothing listening yet)".
16295
+ #
16296
+ # \u{1F6A8} It answers 503 until the engine has registered the worker, on purpose. Answering 200 straight
16297
+ # away would mark the deploy live a moment before a failed registration crashes the process.
16298
+ _registered = threading.Event()
16299
+
16300
+
16301
+ class _Health(BaseHTTPRequestHandler):
16302
+ def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler's spelling
16303
+ ready = _registered.is_set()
16304
+ body = json.dumps(
16305
+ {"worker": WORKER_NAME, "status": "registered" if ready else "starting"}
16306
+ ).encode()
16307
+ self.send_response(200 if ready else 503)
16308
+ self.send_header("content-type", "application/json")
16309
+ self.send_header("content-length", str(len(body)))
16310
+ self.end_headers()
16311
+ self.wfile.write(body)
16312
+
16313
+ def log_message(self, *args: object) -> None:
16314
+ # The default handler writes every request to stderr, which would drown the worker's own
16315
+ # logs: the deploy gate polls this endpoint every two seconds.
16316
+ pass
16317
+
16318
+
16319
+ def _serve_health() -> None:
16320
+ port = int(os.environ.get("PORT", "8080"))
16321
+ HTTPServer(("0.0.0.0", port), _Health).serve_forever()
16322
+
16323
+
16324
+ def _watch_registration(worker: object) -> None:
16325
+ # worker.start() blocks, so readiness is observed from a thread. The status enum is compared by
16326
+ # name rather than imported: it lives at a private-ish path in the SDK and this keeps the
16327
+ # scaffold working across SDK reshuffles.
16328
+ #
16329
+ # \u{1F6A8} If the SDK ever renames or drops .status, this loop would spin forever, / would answer 503
16330
+ # forever, and the deploy would be failed by the health gate after three minutes with nothing in
16331
+ # the log to explain it. Say so once instead: a deploy that fails is fine, a deploy that fails
16332
+ # silently is not.
16333
+ if not hasattr(worker, "status"):
16334
+ print(
16335
+ "[worker] this SDK build has no 'status' attribute, so readiness cannot be observed; "
16336
+ "the health endpoint will stay 503 and Kryd will fail the deploy.",
16337
+ flush=True,
16338
+ )
16339
+ return
16340
+
16341
+ while not _registered.is_set():
16342
+ if getattr(getattr(worker, "status", None), "name", "") == "HEALTHY":
16343
+ _registered.set()
16344
+ return
16345
+ time.sleep(0.2)
16346
+
16347
+
16348
+ def main() -> None:
16349
+ threading.Thread(target=_serve_health, daemon=True).start()
16350
+
16351
+ # Imported here rather than at the top of the file, and that is deliberate: creating the client
16352
+ # reads and decodes HATCHET_CLIENT_TOKEN, and a token it cannot parse raises during import \u2014
16353
+ # before the listener above ever binds. The deploy would then fail with "connection refused
16354
+ # (nothing listening yet)", which is the one message this whole scaffold exists to stop you
16355
+ # seeing. Binding first means a bad token shows up as a 503 plus a readable error in your log.
16356
+ from hatchet_client import hatchet
16357
+ from tasks import greet
16358
+
16359
+ worker = hatchet.worker(WORKER_NAME, workflows=[greet])
16360
+ threading.Thread(target=_watch_registration, args=(worker,), daemon=True).start()
16361
+ worker.start()
16362
+
16363
+
16364
+ if __name__ == "__main__":
16365
+ main()
16366
+ `
16367
+ },
16368
+ {
16369
+ path: ".gitignore",
16370
+ contents: `__pycache__/
16371
+ *.py[cod]
16372
+ .venv/
16373
+ venv/
16374
+ .env
16375
+ .env.local
16376
+ `
16377
+ },
16378
+ {
16379
+ path: "README.md",
16380
+ contents: `# ${workerName}
16381
+
16382
+ A Hatchet workflow worker, ready to deploy on Kryd.
16383
+
16384
+ - \`tasks.py\` holds your tasks. \`main.py\` starts the worker and serves the health endpoint Kryd's
16385
+ deploy gate needs \u2014 keep that listener, and keep \`main.py\` at the root under that name, or your
16386
+ deploys will fail.
16387
+ - **\`HATCHET_CLIENT_TOKEN\` only exists inside a deployed container.** Kryd never prints it, so you
16388
+ cannot run this worker against your tenant from your laptop today.
16389
+ - Deploy it: \`kryd workflow add\` once, then \`kryd push\`.
16390
+
16391
+ Full docs: https://docs.kryd.eu/docs/workflows
16392
+ `
16393
+ }
16394
+ ]
16395
+ };
16396
+
16397
+ // src/scaffold/go.ts
16398
+ var GO_TEMPLATE = {
16399
+ firstCommand: "go mod tidy",
16400
+ files: (workerName) => [
16401
+ {
16402
+ path: "go.mod",
16403
+ contents: `module kryd.local/${workerName}
16404
+
16405
+ go 1.26
16406
+
16407
+ toolchain go1.26.0
16408
+
16409
+ require github.com/hatchet-dev/hatchet v0.106.10
16410
+ `
16411
+ },
16412
+ {
16413
+ path: "tasks.go",
16414
+ contents: `package main
16415
+
16416
+ import (
16417
+ hatchet "github.com/hatchet-dev/hatchet/sdks/go"
16418
+ )
16419
+
16420
+ // GreetInput is what a run is triggered with.
16421
+ type GreetInput struct {
16422
+ Name string \`json:"name"\`
16423
+ }
16424
+
16425
+ // GreetOutput is what the task returns.
16426
+ type GreetOutput struct {
16427
+ Greeting string \`json:"greeting"\`
16428
+ }
16429
+
16430
+ // Greet is one task, to prove the wiring end to end. Add your own beside it and register them in
16431
+ // main.go \u2014 everything about how a task runs (retries, timeouts, concurrency, crons, DAGs) belongs
16432
+ // to the engine's SDK, not to Kryd.
16433
+ func Greet(c *hatchet.Client) *hatchet.StandaloneTask {
16434
+ return c.NewStandaloneTask("greet", func(ctx hatchet.Context, input GreetInput) (GreetOutput, error) {
16435
+ name := input.Name
16436
+ if name == "" {
16437
+ name = "world"
16438
+ }
16439
+ return GreetOutput{Greeting: "Hello, " + name}, nil
16440
+ })
16441
+ }
16442
+ `
16443
+ },
16444
+ {
16445
+ path: "main.go",
16446
+ contents: `// Worker entry point.
16447
+ //
16448
+ // \u{1F6A8} This file must stay at the project root: Kryd's builder compiles the root package when one
16449
+ // exists, and otherwise picks the first cmd/* directory alphabetically \u2014 which is how a trigger
16450
+ // script ends up deployed instead of the worker.
16451
+ package main
16452
+
16453
+ import (
16454
+ "encoding/json"
16455
+ "log"
16456
+ "net/http"
16457
+ "os"
16458
+ "sync/atomic"
16459
+
16460
+ "github.com/hatchet-dev/hatchet/pkg/cmdutils"
16461
+ hatchet "github.com/hatchet-dev/hatchet/sdks/go"
16462
+ )
16463
+
16464
+ const workerName = ${JSON.stringify(workerName)}
16465
+
16466
+ // The health listener, and the one piece of this file that is about Kryd rather than about
16467
+ // workflows.
16468
+ //
16469
+ // A worker holds an outbound gRPC session and listens on no port of its own, but Kryd decides a
16470
+ // deploy is live by asking GET / for a 2xx. Without this the deploy is torn down with
16471
+ // "connection refused (nothing listening yet)".
16472
+ //
16473
+ // \u{1F6A8} It answers 503 until the engine has registered the worker, on purpose. Answering 200 straight
16474
+ // away would mark the deploy live a moment before a failed registration exits the process.
16475
+ var registered atomic.Bool
16476
+
16477
+ func health(w http.ResponseWriter, _ *http.Request) {
16478
+ ready := registered.Load()
16479
+ status := "starting"
16480
+ if ready {
16481
+ status = "registered"
16482
+ }
16483
+ w.Header().Set("content-type", "application/json")
16484
+ if ready {
16485
+ w.WriteHeader(http.StatusOK)
16486
+ } else {
16487
+ w.WriteHeader(http.StatusServiceUnavailable)
16488
+ }
16489
+ _ = json.NewEncoder(w).Encode(map[string]string{"worker": workerName, "status": status})
16490
+ }
16491
+
16492
+ func main() {
16493
+ port := os.Getenv("PORT")
16494
+ if port == "" {
16495
+ port = "8080"
16496
+ }
16497
+
16498
+ mux := http.NewServeMux()
16499
+ mux.HandleFunc("/", health)
16500
+ // Served before the worker connects, so the gate gets a 503 rather than a refused connection
16501
+ // while registration is still in flight.
16502
+ go func() {
16503
+ if err := http.ListenAndServe(":"+port, mux); err != nil {
16504
+ log.Fatalf("health listener stopped: %v", err)
16505
+ }
16506
+ }()
16507
+
16508
+ // Reads HATCHET_CLIENT_TOKEN from the environment and needs nothing else: the token carries the
16509
+ // engine's addresses. Kryd injects it on every deploy once you have run \`kryd workflow add\`.
16510
+ client, err := hatchet.NewClient()
16511
+ if err != nil {
16512
+ log.Fatalf("could not create the Hatchet client: %v", err)
16513
+ }
16514
+
16515
+ worker, err := client.NewWorker(workerName, hatchet.WithWorkflows(Greet(client)))
16516
+ if err != nil {
16517
+ log.Fatalf("could not create the worker: %v", err)
16518
+ }
16519
+
16520
+ // Start returns once the engine has registered the worker, so the flag flips exactly then.
16521
+ cleanup, err := worker.Start()
16522
+ if err != nil {
16523
+ log.Fatalf("could not start the worker: %v", err)
16524
+ }
16525
+ registered.Store(true)
16526
+
16527
+ ctx, cancel := cmdutils.NewInterruptContext()
16528
+ defer cancel()
16529
+ <-ctx.Done()
16530
+
16531
+ if err := cleanup(); err != nil {
16532
+ log.Fatalf("worker shutdown failed: %v", err)
16533
+ }
16534
+ }
16535
+ `
16536
+ },
16537
+ {
16538
+ path: ".gitignore",
16539
+ contents: `/${workerName}
16540
+ *.exe
16541
+ .env
16542
+ .env.local
16543
+ `
16544
+ },
16545
+ {
16546
+ path: "README.md",
16547
+ contents: `# ${workerName}
16548
+
16549
+ A Hatchet workflow worker, ready to deploy on Kryd.
16550
+
16551
+ - Run \`go mod tidy\` once before your first push \u2014 no \`go.sum\` ships with the scaffold.
16552
+ - \`tasks.go\` holds your tasks. \`main.go\` starts the worker and serves the health endpoint Kryd's
16553
+ deploy gate needs \u2014 keep that listener, and keep \`main.go\` at the root, or your deploys will fail.
16554
+ - **\`HATCHET_CLIENT_TOKEN\` only exists inside a deployed container.** Kryd never prints it, so you
16555
+ cannot run this worker against your tenant from your laptop today.
16556
+ - Deploy it: \`kryd workflow add\` once, then \`kryd push\`.
16557
+
16558
+ Full docs: https://docs.kryd.eu/docs/workflows
16559
+ `
16560
+ }
16561
+ ]
16562
+ };
16563
+
16564
+ // src/scaffold/index.ts
16565
+ var SCAFFOLD_LANGUAGES = ["typescript", "python", "go"];
16566
+ function isScaffoldLanguage(value) {
16567
+ return SCAFFOLD_LANGUAGES.includes(value);
16568
+ }
16569
+ var TEMPLATES = {
16570
+ typescript: TYPESCRIPT_TEMPLATE,
16571
+ python: PYTHON_TEMPLATE,
16572
+ go: GO_TEMPLATE
16573
+ };
16574
+ function declarationFile() {
16575
+ return {
16576
+ path: DECLARATION_FILE,
16577
+ contents: `${JSON.stringify({ kind: DECLARATION_KIND_WORKFLOW }, null, 2)}
16578
+ `
16579
+ };
16580
+ }
16581
+ function scaffoldFiles(language, workerName) {
16582
+ return [...TEMPLATES[language].files(workerName), declarationFile()];
16583
+ }
16584
+ function firstCommand(language) {
16585
+ return TEMPLATES[language].firstCommand;
16586
+ }
16587
+ function workerNameFrom(directoryName) {
16588
+ const slug = directoryName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48);
16589
+ return slug || "kryd-worker";
16590
+ }
16591
+
15944
16592
  // src/progress.ts
15945
16593
  var STEPS = DEPLOY_STATES.filter(
15946
16594
  (s) => !TERMINAL_DEPLOY_STATES.includes(s)
@@ -16271,10 +16919,10 @@ async function promptHidden(question, io) {
16271
16919
  terminal: true
16272
16920
  });
16273
16921
  try {
16274
- return await new Promise((resolve2) => {
16275
- rl.once("SIGINT", () => resolve2(null));
16276
- rl.once("close", () => resolve2(null));
16277
- rl.question("", (answer) => resolve2(answer));
16922
+ return await new Promise((resolve3) => {
16923
+ rl.once("SIGINT", () => resolve3(null));
16924
+ rl.once("close", () => resolve3(null));
16925
+ rl.question("", (answer) => resolve3(answer));
16278
16926
  });
16279
16927
  } finally {
16280
16928
  rl.close();
@@ -16286,10 +16934,10 @@ async function promptHidden(question, io) {
16286
16934
  async function promptLine(question, io) {
16287
16935
  const rl = createInterface({ input: io.input, output: io.output });
16288
16936
  try {
16289
- return await new Promise((resolve2) => {
16290
- rl.once("SIGINT", () => resolve2(null));
16291
- rl.once("close", () => resolve2(null));
16292
- rl.question(question, (answer) => resolve2(answer));
16937
+ return await new Promise((resolve3) => {
16938
+ rl.once("SIGINT", () => resolve3(null));
16939
+ rl.once("close", () => resolve3(null));
16940
+ rl.question(question, (answer) => resolve3(answer));
16293
16941
  });
16294
16942
  } finally {
16295
16943
  rl.close();
@@ -16298,10 +16946,10 @@ async function promptLine(question, io) {
16298
16946
  async function promptConfirm(question, io) {
16299
16947
  const rl = createInterface({ input: io.input, output: io.output });
16300
16948
  try {
16301
- const answer = await new Promise((resolve2) => {
16302
- rl.once("SIGINT", () => resolve2(null));
16303
- rl.once("close", () => resolve2(null));
16304
- rl.question(`${question} [y/N] `, (a) => resolve2(a));
16949
+ const answer = await new Promise((resolve3) => {
16950
+ rl.once("SIGINT", () => resolve3(null));
16951
+ rl.once("close", () => resolve3(null));
16952
+ rl.question(`${question} [y/N] `, (a) => resolve3(a));
16305
16953
  });
16306
16954
  return answer !== null && /^y(es)?$/i.test(answer.trim());
16307
16955
  } finally {
@@ -16311,12 +16959,7 @@ async function promptConfirm(question, io) {
16311
16959
 
16312
16960
  // src/git.ts
16313
16961
  import { execFileSync, spawnSync } from "node:child_process";
16314
- function authenticatedRemoteUrl(cloneUrl, username, token) {
16315
- const prefix = "https://";
16316
- if (!cloneUrl.startsWith(prefix)) return cloneUrl;
16317
- const creds = `${encodeURIComponent(username)}:${encodeURIComponent(token)}@`;
16318
- return prefix + creds + cloneUrl.slice(prefix.length);
16319
- }
16962
+ import { resolve } from "node:path";
16320
16963
  function configureGitRemote(cwd, remote, url2) {
16321
16964
  const run = (args) => execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
16322
16965
  try {
@@ -16375,6 +17018,21 @@ function gitAvailable(cwd) {
16375
17018
  if (res.status !== "ok" || res.value !== "true") return { status: "not-a-repo" };
16376
17019
  return { status: "ok" };
16377
17020
  }
17021
+ function initRepo(cwd) {
17022
+ const existing = gitAvailable(cwd);
17023
+ if (existing.status === "no-git") return existing;
17024
+ if (existing.status === "ok") {
17025
+ const top = probe(cwd, ["rev-parse", "--show-toplevel"]);
17026
+ if (top.status === "ok" && resolve(top.value) !== resolve(cwd)) {
17027
+ return { status: "nested", root: resolve(top.value) };
17028
+ }
17029
+ return { status: "already" };
17030
+ }
17031
+ const res = probe(cwd, ["init", "--quiet"]);
17032
+ if (res.status === "no-git") return res;
17033
+ if (res.status !== "ok") return { status: "failed" };
17034
+ return { status: "ok" };
17035
+ }
16378
17036
  function currentBranch(cwd) {
16379
17037
  return probe(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
16380
17038
  }
@@ -16394,9 +17052,32 @@ function pushBranch(cwd, remote, branch, extraArgs) {
16394
17052
  if (res.status === 0) return { status: "ok" };
16395
17053
  return { status: "failed", code: res.status ?? 1 };
16396
17054
  }
17055
+ function configureCredentialHelper(cwd, cloneUrl) {
17056
+ const origin = originOf(cloneUrl);
17057
+ if (!origin) return "unavailable";
17058
+ const run = (args) => {
17059
+ try {
17060
+ execFileSync("git", args, { cwd, stdio: ["ignore", "ignore", "ignore"] });
17061
+ return true;
17062
+ } catch {
17063
+ return false;
17064
+ }
17065
+ };
17066
+ const ok = run(["config", `credential.${origin}.helper`, ""]) && run(["config", "--add", `credential.${origin}.helper`, "!kryd git-credential"]) && run(["config", `credential.${origin}.useHttpPath`, "true"]);
17067
+ return ok ? "set" : "unavailable";
17068
+ }
17069
+ function originOf(url2) {
17070
+ try {
17071
+ const parsed = new URL(url2);
17072
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null;
17073
+ return `${parsed.protocol}//${parsed.host}`;
17074
+ } catch {
17075
+ return null;
17076
+ }
17077
+ }
16397
17078
 
16398
17079
  // src/index.ts
16399
- function reportError(err) {
17080
+ async function reportError(err) {
16400
17081
  if (err instanceof ApiError && err.envelope) {
16401
17082
  process.stderr.write(
16402
17083
  `${err.envelope.error.code}: ${err.envelope.error.message}
@@ -16407,6 +17088,63 @@ function reportError(err) {
16407
17088
  `);
16408
17089
  }
16409
17090
  process.exitCode = 1;
17091
+ if (err instanceof ApiError) {
17092
+ process.stderr.write(await explainMissingProject(err));
17093
+ }
17094
+ }
17095
+ async function projectIsVisible(token, projectId) {
17096
+ try {
17097
+ return await getProject(currentApiUrl(), token, projectId) !== null;
17098
+ } catch {
17099
+ return null;
17100
+ }
17101
+ }
17102
+ function accountLabel(slug, id) {
17103
+ return slug ?? id;
17104
+ }
17105
+ async function explainMissingProject(err) {
17106
+ if (err.status !== 404) return "";
17107
+ const resolution = resolvedLink();
17108
+ if (!resolution) return "";
17109
+ const token = loadConfig().token;
17110
+ if (!token) return "";
17111
+ if (await projectIsVisible(token, resolution.link.projectId) !== false) return "";
17112
+ let session;
17113
+ try {
17114
+ session = await whoami(currentApiUrl(), token);
17115
+ } catch {
17116
+ return "";
17117
+ }
17118
+ if (!session.accountId) return "";
17119
+ const signedInAs = accountLabel(session.accountSlug, session.accountId);
17120
+ const linkedAccount = resolution.link.accountId;
17121
+ if (!linkedAccount) {
17122
+ return `This folder is linked to project ${resolution.link.projectId}, and you are signed in as ${signedInAs}. That project is not on this account \u2014 if it belongs to another one, switch with \`kryd login\` and run this again.
17123
+ `;
17124
+ }
17125
+ if (linkedAccount === session.accountId) {
17126
+ return `This folder is linked to project ${resolution.link.projectId} on ${signedInAs}, which is the account you are signed in as \u2014 so that project no longer exists.
17127
+ `;
17128
+ }
17129
+ return `This folder is linked to a project of account ${accountLabel(resolution.link.accountSlug, linkedAccount)}, but you are signed in as ${signedInAs}. Switch with \`kryd login\` and run this again.
17130
+ `;
17131
+ }
17132
+ async function backfillLinkAccount() {
17133
+ if (process.exitCode) return;
17134
+ const resolution = resolvedLink();
17135
+ if (!resolution) return;
17136
+ if (resolution.link.accountId && resolution.link.accountSlug) return;
17137
+ const token = loadConfig().token;
17138
+ if (!token) return;
17139
+ if (await projectIsVisible(token, resolution.link.projectId) !== true) return;
17140
+ try {
17141
+ const session = await whoami(currentApiUrl(), token);
17142
+ rememberLinkAccount(resolution.root, {
17143
+ accountId: session.accountId,
17144
+ accountSlug: session.accountSlug
17145
+ });
17146
+ } catch {
17147
+ }
16410
17148
  }
16411
17149
  function reportNotLinked(example) {
16412
17150
  process.stderr.write(
@@ -16447,7 +17185,7 @@ async function runWhoami(opts = {}) {
16447
17185
  `
16448
17186
  );
16449
17187
  } catch (err) {
16450
- reportError(err);
17188
+ await reportError(err);
16451
17189
  }
16452
17190
  }
16453
17191
  function runLogout() {
@@ -16463,12 +17201,31 @@ async function runInit(opts) {
16463
17201
  process.exitCode = 1;
16464
17202
  return;
16465
17203
  }
17204
+ const existingLink = directoryIsLinked(cwd);
17205
+ if (existingLink) {
17206
+ const remote = hasRemote(cwd, "kryd");
17207
+ const remoteConfigured = remote.status === "ok" && remote.value;
17208
+ if (remoteConfigured) {
17209
+ process.stderr.write(
17210
+ `This folder is already linked to ${existingLink.projectId} (.kryd/project.json).
17211
+ Running \`kryd init\` again would create a SECOND project and leave that one orphaned.
17212
+ To deploy it: \`kryd push\`. To start over: \`kryd project rm\` first, or delete .kryd/project.json to unlink this folder.
17213
+ `
17214
+ );
17215
+ process.exitCode = 1;
17216
+ return;
17217
+ }
17218
+ process.stdout.write(
17219
+ `This folder is linked to ${existingLink.projectId} but has no \`kryd\` remote \u2014 finishing that setup.
17220
+ `
17221
+ );
17222
+ }
16466
17223
  const detected = inspectProject(cwd);
16467
17224
  let framework;
16468
17225
  if (opts.framework) {
16469
17226
  if (!isFramework(opts.framework)) {
16470
17227
  process.stderr.write(
16471
- `Unknown framework "${opts.framework}" \u2014 expected react-router, nextjs, vite-spa, or node.
17228
+ `Unknown framework "${opts.framework}" \u2014 expected react-router, nextjs, vite-spa, node, or workflow.
16472
17229
  `
16473
17230
  );
16474
17231
  process.exitCode = 1;
@@ -16478,7 +17235,7 @@ async function runInit(opts) {
16478
17235
  } else {
16479
17236
  if (!detected.framework) {
16480
17237
  process.stderr.write(
16481
- "Could not detect a supported framework (React Router, Next.js, a Vite SPA, or a Node service). Pass --framework <react-router|nextjs|vite-spa|node>.\n"
17238
+ "Could not detect a supported framework (React Router, Next.js, a Vite SPA, or a Node service). Pass --framework <react-router|nextjs|vite-spa|node>, or run `kryd workflow init` here to scaffold a workflow worker.\n"
16482
17239
  );
16483
17240
  process.exitCode = 1;
16484
17241
  return;
@@ -16487,7 +17244,7 @@ async function runInit(opts) {
16487
17244
  }
16488
17245
  const name = opts.name ?? detected.name;
16489
17246
  try {
16490
- const { project: project2, repo, pushToken } = await linkProject(apiUrl, token, {
17247
+ const { project: project2, repo } = await linkProject(apiUrl, token, {
16491
17248
  name,
16492
17249
  framework,
16493
17250
  // First `kryd init` for the account claims the tenant slug (the vanity routing key in
@@ -16501,6 +17258,14 @@ async function runInit(opts) {
16501
17258
  let linkNote = "";
16502
17259
  try {
16503
17260
  saveProjectLink(cwd, { projectId: project2.id });
17261
+ try {
17262
+ const session = await whoami(apiUrl, token);
17263
+ rememberLinkAccount(cwd, {
17264
+ accountId: session.accountId,
17265
+ accountSlug: session.accountSlug
17266
+ });
17267
+ } catch {
17268
+ }
16504
17269
  linkNote = "Linked this folder \u2192 .kryd/project.json \u2014 `kryd deploy` / `kryd logs` now work here with no id.\n";
16505
17270
  try {
16506
17271
  ensureKrydIgnored(cwd);
@@ -16517,44 +17282,49 @@ async function runInit(opts) {
16517
17282
  );
16518
17283
  }
16519
17284
  const branch = repo.defaultBranch;
16520
- const slug = repo.fullName.split("/")[0] ?? "kryd";
16521
- const authedUrl = authenticatedRemoteUrl(repo.cloneUrl, slug, pushToken);
16522
- const remote = configureGitRemote(cwd, "kryd", authedUrl);
17285
+ const remote = configureGitRemote(cwd, "kryd", repo.cloneUrl);
17286
+ const helper = configureCredentialHelper(cwd, repo.cloneUrl);
16523
17287
  let nextStep;
16524
17288
  switch (remote.status) {
16525
17289
  case "added":
16526
17290
  case "updated":
16527
- nextStep = `${remote.status === "added" ? "Added" : "Updated"} git remote "${remote.remote}" (with your push credential).
16528
- ` + (remote.tracking === "set" ? `Tracking set: \`git push\` and \`kryd push\` both deploy this branch.
17291
+ nextStep = `${remote.status === "added" ? "Added" : "Updated"} git remote "${remote.remote}".
17292
+ ` + (helper === "set" ? "" : `\u26A0\uFE0F Could not register the credential helper, so git will ask for a password on push.
17293
+ Run \`kryd login\` and re-run \`kryd init\` here to fix it.
17294
+ `) + (remote.tracking === "set" ? `Tracking set: \`git push\` and \`kryd push\` both deploy this branch.
16529
17295
  ` : remote.tracking === "kept-existing" ? `This branch already tracks another remote, so it was left alone \u2014 deploy with \`kryd push\`.
16530
17296
  ` : "") + `Next: kryd push # push to deploy (same as \`git push ${remote.remote} ${branch}\`)
16531
17297
  `;
16532
17298
  break;
16533
17299
  case "not-a-repo":
16534
- nextStep = `No git repo here yet. To deploy (the URL carries your push token \u2014 keep it private):
17300
+ nextStep = `No git repo here yet. To deploy:
16535
17301
  git init && git add -A && git commit -m "init"
16536
- git remote add kryd ${authedUrl}
17302
+ git remote add kryd ${repo.cloneUrl}
17303
+ kryd init # again, to register the credential helper
16537
17304
  kryd push
16538
17305
  `;
16539
17306
  break;
16540
17307
  case "unavailable":
16541
17308
  nextStep = gitAvailable(cwd).status === "no-git" ? `git is not installed, so the deploy remote could not be configured.
16542
17309
  Install git (https://git-scm.com/downloads), then re-run \`kryd init\` here.
16543
- ` : `Add the remote, then push to deploy (the URL carries your push token \u2014 keep it private):
16544
- git remote add kryd ${authedUrl}
17310
+ ` : `Add the remote, then push to deploy:
17311
+ git remote add kryd ${repo.cloneUrl}
16545
17312
  kryd push
16546
17313
  `;
16547
17314
  break;
16548
17315
  }
17316
+ const routing = project2.framework === "workflow" ? `No public URL: a workflow worker dials out to the engine and serves nothing.
17317
+ Scheduled and triggered work runs on your production deploy; preview branches build but do not start a worker.
17318
+ ` : `Production URL: https://${project2.subdomain}.${PRODUCTION_TLD}
17319
+ Preview branches deploy to https://<branch>-<hash>-${project2.subdomain}.${PREVIEW_TLD}
17320
+ `;
16549
17321
  process.stdout.write(
16550
17322
  `Linked "${name}" (${project2.framework}) \u2192 ${repo.htmlUrl}
16551
- Production URL: https://${project2.subdomain}.${PRODUCTION_TLD}
16552
- Preview branches deploy to https://<branch>-<hash>-${project2.subdomain}.${PREVIEW_TLD}
16553
- ${linkNote}
17323
+ ` + routing + `${linkNote}
16554
17324
  ${nextStep}`
16555
17325
  );
16556
17326
  } catch (err) {
16557
- reportError(err);
17327
+ await reportError(err);
16558
17328
  }
16559
17329
  }
16560
17330
  function formatStatus(event) {
@@ -16670,7 +17440,7 @@ async function runLogs(opts) {
16670
17440
  }
16671
17441
  await followDeploy(apiUrl, token, deploymentId);
16672
17442
  } catch (err) {
16673
- reportError(err);
17443
+ await reportError(err);
16674
17444
  }
16675
17445
  }
16676
17446
  async function runRuntimeLogs(opts) {
@@ -16717,7 +17487,7 @@ async function runRuntimeLogs(opts) {
16717
17487
  }
16718
17488
  );
16719
17489
  } catch (err) {
16720
- reportError(err);
17490
+ await reportError(err);
16721
17491
  } finally {
16722
17492
  process.off("SIGINT", onSigint);
16723
17493
  }
@@ -16741,7 +17511,7 @@ async function runDeploy(opts) {
16741
17511
  `);
16742
17512
  await followDeploy(apiUrl, token, deploymentId, { render: renderFor(opts.logs) });
16743
17513
  } catch (err) {
16744
- reportError(err);
17514
+ await reportError(err);
16745
17515
  }
16746
17516
  }
16747
17517
  function renderFor(logs) {
@@ -16879,7 +17649,7 @@ async function runPush(opts) {
16879
17649
  estimateFrom: recent
16880
17650
  });
16881
17651
  } catch (err) {
16882
- reportError(err);
17652
+ await reportError(err);
16883
17653
  }
16884
17654
  }
16885
17655
  async function runRollback(opts) {
@@ -16909,7 +17679,7 @@ async function runRollback(opts) {
16909
17679
  );
16910
17680
  await followDeploy(apiUrl, token, deploymentId, { render: renderFor(opts.logs) });
16911
17681
  } catch (err) {
16912
- reportError(err);
17682
+ await reportError(err);
16913
17683
  }
16914
17684
  }
16915
17685
  async function runRedeploy(opts) {
@@ -16938,7 +17708,7 @@ ${note}
16938
17708
  );
16939
17709
  await followDeploy(apiUrl, token, deploymentId, { render: renderFor(opts.logs) });
16940
17710
  } catch (err) {
16941
- reportError(err);
17711
+ await reportError(err);
16942
17712
  }
16943
17713
  }
16944
17714
  async function runDbAdd(opts) {
@@ -17003,7 +17773,7 @@ The connection string will be injected as DATABASE_URL on your next deploy.
17003
17773
  retryCmd: `kryd db status ${project2}`
17004
17774
  });
17005
17775
  } catch (err) {
17006
- reportError(err);
17776
+ await reportError(err);
17007
17777
  }
17008
17778
  }
17009
17779
  function reportSettleResult(result, opts) {
@@ -17050,7 +17820,7 @@ Its S3 credentials will be injected on your next deploy.
17050
17820
  retryCmd: `kryd storage status ${project2}`
17051
17821
  });
17052
17822
  } catch (err) {
17053
- reportError(err);
17823
+ await reportError(err);
17054
17824
  }
17055
17825
  }
17056
17826
  function reportDetachResult(result, opts) {
@@ -17093,7 +17863,7 @@ async function resolveProjectIdByName(apiUrl, token, name) {
17093
17863
  try {
17094
17864
  projects = await listProjects(apiUrl, token);
17095
17865
  } catch (err) {
17096
- reportError(err);
17866
+ await reportError(err);
17097
17867
  return null;
17098
17868
  }
17099
17869
  const matches = projects.filter((p) => p.name === name);
@@ -17154,7 +17924,7 @@ async function runProjectRemove(opts) {
17154
17924
  }
17155
17925
  resources = project2.status === "delete_failed" ? ["whatever the previous attempt did not manage to remove"] : await describeProjectResources(apiUrl, token, projectId);
17156
17926
  } catch (err) {
17157
- reportError(err);
17927
+ await reportError(err);
17158
17928
  return;
17159
17929
  }
17160
17930
  if (!opts.yes) {
@@ -17191,7 +17961,7 @@ async function runProjectRemove(opts) {
17191
17961
  );
17192
17962
  await followProjectDeletion(apiUrl, token, projectId, project2.name, opts);
17193
17963
  } catch (err) {
17194
- reportError(err);
17964
+ await reportError(err);
17195
17965
  }
17196
17966
  }
17197
17967
  async function followProjectDeletion(apiUrl, token, projectId, name, opts) {
@@ -17267,7 +18037,7 @@ async function runDbRemove(opts) {
17267
18037
  retryCmd: `kryd db remove ${project2}`
17268
18038
  });
17269
18039
  } catch (err) {
17270
- reportError(err);
18040
+ await reportError(err);
17271
18041
  }
17272
18042
  }
17273
18043
  async function runAiAdd(opts) {
@@ -17292,7 +18062,7 @@ async function runAiAdd(opts) {
17292
18062
  `
17293
18063
  );
17294
18064
  } catch (err) {
17295
- reportError(err);
18065
+ await reportError(err);
17296
18066
  }
17297
18067
  }
17298
18068
  async function runAiRemove(opts) {
@@ -17312,7 +18082,7 @@ async function runAiRemove(opts) {
17312
18082
  try {
17313
18083
  current = await getAiStatus(apiUrl, token, project2);
17314
18084
  } catch (err) {
17315
- reportError(err);
18085
+ await reportError(err);
17316
18086
  return;
17317
18087
  }
17318
18088
  if (!current.enabled) {
@@ -17337,7 +18107,7 @@ AI_GATEWAY_URL and AI_GATEWAY_TOKEN stop being injected from your next deploy (k
17337
18107
  `
17338
18108
  );
17339
18109
  } catch (err) {
17340
- reportError(err);
18110
+ await reportError(err);
17341
18111
  }
17342
18112
  }
17343
18113
  async function reportResourceStatus(opts) {
@@ -17354,12 +18124,12 @@ Remove it before attaching another.
17354
18124
  process.stdout.write(`${opts.project}: ${opts.noun} ${result.status}.
17355
18125
  `);
17356
18126
  } catch (err) {
17357
- if (err instanceof ApiError && err.status === 404) {
18127
+ if (err instanceof ApiError && err.status === 404 && await projectIsVisible(opts.token, opts.project) === true) {
17358
18128
  process.stdout.write(`${opts.project}: no ${opts.noun} attached.
17359
18129
  `);
17360
18130
  return;
17361
18131
  }
17362
- reportError(err);
18132
+ await reportError(err);
17363
18133
  }
17364
18134
  }
17365
18135
  async function runDbStatus(opts) {
@@ -17378,7 +18148,8 @@ async function runDbStatus(opts) {
17378
18148
  await reportResourceStatus({
17379
18149
  read: () => getDatabaseStatus(apiUrl, token, project2),
17380
18150
  noun: "database",
17381
- project: project2
18151
+ project: project2,
18152
+ token
17382
18153
  });
17383
18154
  }
17384
18155
  async function runStorageStatus(opts) {
@@ -17397,7 +18168,8 @@ async function runStorageStatus(opts) {
17397
18168
  await reportResourceStatus({
17398
18169
  read: () => getStorageStatus(apiUrl, token, project2),
17399
18170
  noun: "object storage",
17400
- project: project2
18171
+ project: project2,
18172
+ token
17401
18173
  });
17402
18174
  }
17403
18175
  async function runAiStatus(opts) {
@@ -17426,7 +18198,7 @@ async function runAiStatus(opts) {
17426
18198
  `
17427
18199
  );
17428
18200
  } catch (err) {
17429
- reportError(err);
18201
+ await reportError(err);
17430
18202
  }
17431
18203
  }
17432
18204
  function isoDay(iso) {
@@ -17466,7 +18238,7 @@ HATCHET_CLIENT_TOKEN will be injected on your next deploy (kryd deploy)` + (stat
17466
18238
  ` : ".\n")
17467
18239
  );
17468
18240
  } catch (err) {
17469
- reportError(err);
18241
+ await reportError(err);
17470
18242
  }
17471
18243
  }
17472
18244
  async function runWorkflowRemove(opts) {
@@ -17486,7 +18258,7 @@ async function runWorkflowRemove(opts) {
17486
18258
  try {
17487
18259
  current = await getWorkflowStatus(apiUrl, token, project2);
17488
18260
  } catch (err) {
17489
- reportError(err);
18261
+ await reportError(err);
17490
18262
  return;
17491
18263
  }
17492
18264
  if (!current.enabled) {
@@ -17511,7 +18283,7 @@ Workflow history, crons and schedules stay until the project is deleted.
17511
18283
  `
17512
18284
  );
17513
18285
  } catch (err) {
17514
- reportError(err);
18286
+ await reportError(err);
17515
18287
  }
17516
18288
  }
17517
18289
  async function runWorkflowStatus(opts) {
@@ -17551,8 +18323,122 @@ async function runWorkflowStatus(opts) {
17551
18323
  );
17552
18324
  }
17553
18325
  } catch (err) {
17554
- reportError(err);
18326
+ await reportError(err);
18327
+ }
18328
+ }
18329
+ async function runWorkflowInit(opts) {
18330
+ const cwd = opts.cwd ?? process.cwd();
18331
+ if (!opts.language) {
18332
+ process.stderr.write(
18333
+ `Pass --language <${SCAFFOLD_LANGUAGES.join("|")}>.
18334
+ `
18335
+ );
18336
+ process.exitCode = 1;
18337
+ return;
18338
+ }
18339
+ if (!isScaffoldLanguage(opts.language)) {
18340
+ process.stderr.write(
18341
+ `Unknown language "${opts.language}" \u2014 expected ${SCAFFOLD_LANGUAGES.join(", ")}.
18342
+ `
18343
+ );
18344
+ process.exitCode = 1;
18345
+ return;
18346
+ }
18347
+ const language = opts.language;
18348
+ const target = opts.dir ? resolve2(cwd, opts.dir) : cwd;
18349
+ const workerName = workerNameFrom(opts.name ?? basename2(target));
18350
+ const files = scaffoldFiles(language, workerName);
18351
+ const clashes = files.map((f) => f.path).filter((p) => existsSync3(join3(target, p)));
18352
+ if (clashes.length > 0) {
18353
+ process.stderr.write(
18354
+ `Refusing to overwrite: ${clashes.join(", ")}.
18355
+ Nothing was written. Run this in an empty directory, or pass --dir <path>.
18356
+ `
18357
+ );
18358
+ process.exitCode = 1;
18359
+ return;
18360
+ }
18361
+ const written = [];
18362
+ try {
18363
+ for (const file2 of files) {
18364
+ const full = join3(target, file2.path);
18365
+ mkdirSync2(dirname2(full), { recursive: true });
18366
+ writeFileSync2(full, file2.contents);
18367
+ written.push(full);
18368
+ }
18369
+ } catch (err) {
18370
+ for (const path of written.reverse()) {
18371
+ try {
18372
+ rmSync2(path);
18373
+ } catch {
18374
+ }
18375
+ }
18376
+ process.stderr.write(
18377
+ `Could not write the scaffold (${err instanceof Error ? err.message : String(err)}).
18378
+ Nothing was left behind.
18379
+ `
18380
+ );
18381
+ process.exitCode = 1;
18382
+ return;
18383
+ }
18384
+ const where = opts.dir ? `${opts.dir}/` : "";
18385
+ process.stdout.write(
18386
+ `Scaffolded a ${language} workflow worker${opts.dir ? ` in ${opts.dir}` : ""}:
18387
+ ` + files.map((f) => ` ${where}${f.path}
18388
+ `).join("") + "\n"
18389
+ );
18390
+ const repo = initRepo(target);
18391
+ if (repo.status === "nested") {
18392
+ process.stderr.write(
18393
+ `That directory is inside the git repository at ${repo.root}.
18394
+ A worker has to be its own repository: Kryd builds one app per repository, and git would
18395
+ write the deploy remote into the enclosing repo, so \`kryd push\` would push that tree instead.
18396
+ The files above were written \u2014 move them to a directory of their own, or run this outside that repository.
18397
+ `
18398
+ );
18399
+ process.exitCode = 1;
18400
+ return;
18401
+ }
18402
+ const next = [];
18403
+ if (opts.dir) next.push(`cd ${opts.dir}`);
18404
+ const first = firstCommand(language);
18405
+ if (first) next.push(first);
18406
+ next.push('git add . && git commit -m "scaffold"');
18407
+ if (opts.link === false) {
18408
+ next.push("kryd init", "kryd workflow add", "kryd push");
18409
+ process.stdout.write(
18410
+ `Next: ${next.map((c) => `\`${c}\``).join(", then ")}.
18411
+ HATCHET_CLIENT_TOKEN is injected into your container on deploy; it is never available locally.
18412
+ `
18413
+ );
18414
+ return;
18415
+ }
18416
+ if (repo.status === "failed") {
18417
+ process.stderr.write(
18418
+ "Could not run `git init` here, so the project was not linked \u2014 that path would print your\npush credential into this terminal. The files above are fine: fix git, run `git init`,\nthen `kryd init` in that directory.\n"
18419
+ );
18420
+ process.exitCode = 1;
18421
+ return;
18422
+ }
18423
+ process.exitCode = 0;
18424
+ await runInit({
18425
+ cwd: target,
18426
+ ...opts.name ? { name: opts.name } : {},
18427
+ ...opts.apiUrl ? { apiUrl: opts.apiUrl } : {}
18428
+ });
18429
+ if (process.exitCode) {
18430
+ process.stderr.write(
18431
+ "\nThe files above were written; only the link failed. Fix the problem above and run `kryd init` here.\n"
18432
+ );
18433
+ return;
17555
18434
  }
18435
+ next.push("kryd workflow add", "kryd push");
18436
+ process.stdout.write(
18437
+ `
18438
+ Next: ${next.map((c) => `\`${c}\``).join(", then ")}.
18439
+ HATCHET_CLIENT_TOKEN is injected into your container on deploy; it is never available locally.
18440
+ `
18441
+ );
17556
18442
  }
17557
18443
  async function runStorageRemove(opts) {
17558
18444
  const apiUrl = resolveApiUrl(opts.apiUrl);
@@ -17590,7 +18476,7 @@ async function runStorageRemove(opts) {
17590
18476
  retryCmd: `kryd storage remove ${project2}`
17591
18477
  });
17592
18478
  } catch (err) {
17593
- reportError(err);
18479
+ await reportError(err);
17594
18480
  }
17595
18481
  }
17596
18482
  function splitKeyValue(spec) {
@@ -17629,7 +18515,7 @@ async function runProjectList(opts) {
17629
18515
  try {
17630
18516
  projects = await listProjects(apiUrl, token);
17631
18517
  } catch (err) {
17632
- reportError(err);
18518
+ await reportError(err);
17633
18519
  return;
17634
18520
  }
17635
18521
  if (projects.length === 0) {
@@ -17737,7 +18623,7 @@ Set one with \`kryd env set KEY --stdin\`, or attach a database, storage or the
17737
18623
  out += "\nValues are never shown. This is the stored set \u2014 runtime changes reach your container at its next deploy (run `kryd redeploy` to apply them now)" + (buildVars.length > 0 ? ", and build-time changes at its next build.\n" : ".\n");
17738
18624
  process.stdout.write(out);
17739
18625
  } catch (err) {
17740
- reportError(err);
18626
+ await reportError(err);
17741
18627
  }
17742
18628
  }
17743
18629
  function toDotenvLine(key, value) {
@@ -17765,7 +18651,7 @@ async function runEnvPull(opts) {
17765
18651
  const cwd = opts.cwd ?? process.cwd();
17766
18652
  const root = findProjectLinkDir(cwd) ?? cwd;
17767
18653
  const outRel = opts.out ?? ".env.kryd";
17768
- const outPath = resolve(root, outRel);
18654
+ const outPath = resolve2(root, outRel);
17769
18655
  if (opts.out !== void 0 && existsSync3(outPath) && !opts.force) {
17770
18656
  process.stderr.write(
17771
18657
  `Refusing to overwrite ${outRel} \u2014 pass --force to replace it (or omit --out to write .env.kryd).
@@ -17806,7 +18692,7 @@ ${body}
17806
18692
  "These are your own values, read back from Kryd. Managed values (DATABASE_URL, storage, the AI gateway) are never pulled.\n"
17807
18693
  );
17808
18694
  } catch (err) {
17809
- reportError(err);
18695
+ await reportError(err);
17810
18696
  }
17811
18697
  }
17812
18698
  async function resolveEnvValue(inline, opts) {
@@ -17939,7 +18825,7 @@ async function runEnvSet(opts) {
17939
18825
  ${NEXT_BUILD_NOTE}` : NEXT_DEPLOY_NOTE)
17940
18826
  );
17941
18827
  } catch (err) {
17942
- reportError(err);
18828
+ await reportError(err);
17943
18829
  }
17944
18830
  }
17945
18831
  async function runEnvRemove(opts) {
@@ -17999,7 +18885,7 @@ Run \`kryd env list\` to see what is set. (If you meant the runtime variable of
17999
18885
  return;
18000
18886
  }
18001
18887
  } catch (err) {
18002
- reportError(err);
18888
+ await reportError(err);
18003
18889
  return;
18004
18890
  }
18005
18891
  }
@@ -18037,21 +18923,32 @@ Run \`kryd env list\` to see what is set. (If you meant the runtime variable of
18037
18923
  `)
18038
18924
  );
18039
18925
  } catch (err) {
18040
- reportError(err);
18926
+ await reportError(err);
18041
18927
  }
18042
18928
  }
18043
- var CLI_VERSION = true ? "0.7.0" : "0.0.0-dev";
18929
+ var CLI_VERSION = true ? "0.8.1" : "0.0.0-dev";
18044
18930
  var program = new Command();
18045
18931
  program.name("kryd").description("Kryd CLI").version(CLI_VERSION);
18932
+ program.hook("postAction", async () => {
18933
+ await backfillLinkAccount();
18934
+ });
18046
18935
  program.command("login").description("Sign in via the browser (default) and store a token").option("--email <email>", "account email (with --password; non-interactive escape hatch)").option("--password <password>", "account password (with --email; visible in `ps` \u2014 prefer the browser flow)").option(
18047
18936
  "--token <token>",
18048
18937
  "supply a token instead of the browser flow (prefer $KRYD_TOKEN \u2014 flags are visible in `ps`)"
18049
18938
  ).option("--api-url <url>", "control-plane API base URL").option("--dashboard-url <url>", "dashboard base URL for the browser approve page (default app.kryd.eu)").action((opts) => runLogin(opts));
18050
18939
  program.command("whoami").description("Show the current account").option("--api-url <url>", "control-plane API base URL").action((opts) => runWhoami(opts));
18051
18940
  program.command("logout").description("Clear the stored token").action(() => runLogout());
18941
+ program.command("git-credential <operation>", { hidden: true }).description("git credential helper (invoked by git, not by you)").option("--api-url <url>", "control-plane API base URL").action(async (operation, opts) => {
18942
+ const chunks = [];
18943
+ for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
18944
+ await runGitCredential(operation, {
18945
+ stdin: Buffer.concat(chunks).toString("utf8"),
18946
+ ...opts.apiUrl ? { apiUrl: opts.apiUrl } : {}
18947
+ });
18948
+ });
18052
18949
  program.command("init").description("Link this project's repo + register its push webhook").option("--name <name>", "project name (defaults to package.json name / dir)").option(
18053
18950
  "--framework <framework>",
18054
- "react-router | nextjs | vite-spa | node (auto-detected if omitted)"
18951
+ "react-router | nextjs | vite-spa | node (auto-detected if omitted; `workflow` comes from a committed kryd.json)"
18055
18952
  ).option(
18056
18953
  "--tenant <slug>",
18057
18954
  // KRYD-265 / KRYD-188: this used to say "claimed once on first init", which was false — signup
@@ -18140,6 +19037,12 @@ var workflow = program.command("workflow").description("Manage the project's dur
18140
19037
  workflow.command("add [project]").description("Give the project a workflow tenant and inject HATCHET_CLIENT_TOKEN on next deploy").option("--api-url <url>", "control-plane API base URL").action((project2, opts) => runWorkflowAdd({ ...opts, project: project2 }));
18141
19038
  workflow.command("remove [project]").description("Revoke the project's workflow token and stop injecting it (asks for confirmation)").option("--yes", "skip the confirmation prompt").option("--api-url <url>", "control-plane API base URL").action((project2, opts) => runWorkflowRemove({ ...opts, project: project2 }));
18142
19039
  workflow.command("status [project]").description("Show whether the project has workflows, and when its token expires").option("--api-url <url>", "control-plane API base URL").action((project2, opts) => runWorkflowStatus({ ...opts, project: project2 }));
19040
+ workflow.command("init").description(
19041
+ "Scaffold a workflow worker here (with the health listener Kryd's deploy gate needs) and link it"
19042
+ ).requiredOption(
19043
+ "--language <language>",
19044
+ `worker language: ${SCAFFOLD_LANGUAGES.join(" | ")}`
19045
+ ).option("--dir <path>", "write into this directory instead of the current one").option("--name <name>", "project and worker name (defaults to the directory name)").option("--no-link", "only write the files; do not create a Kryd project").option("--api-url <url>", "control-plane API base URL").action((opts) => runWorkflowInit(opts));
18143
19046
  function invokedDirectly() {
18144
19047
  const entry = process.argv[1];
18145
19048
  if (!entry) return false;
@@ -18159,6 +19062,7 @@ if (invokedDirectly()) {
18159
19062
  });
18160
19063
  }
18161
19064
  export {
19065
+ backfillLinkAccount,
18162
19066
  program,
18163
19067
  runAiAdd,
18164
19068
  runAiRemove,
@@ -18186,6 +19090,7 @@ export {
18186
19090
  runStorageStatus,
18187
19091
  runWhoami,
18188
19092
  runWorkflowAdd,
19093
+ runWorkflowInit,
18189
19094
  runWorkflowRemove,
18190
19095
  runWorkflowStatus,
18191
19096
  splitKeyValue