@kryd/cli 0.6.0 → 0.8.0

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 +2 -0
  2. package/dist/index.js +916 -25
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -38,6 +38,8 @@ Once you've run `kryd init` in a directory, the commands below work **arg-less**
38
38
  | `kryd storage add \| remove \| status [project]` | Attach, tear down or inspect S3-compatible object storage. `remove` destroys every object and asks first. |
39
39
  | `kryd env list \| set \| rm [project]` | Manage your own environment variables. `set KEY --stdin` (or a prompt) keeps a secret out of `ps` and your shell history; values are never printed back. Add `--build` for build-time variables (`VITE_*`, `NEXT_PUBLIC_*`) — these are compiled into your public bundle, so they must never be secrets, and they take effect at your next build rather than your next deploy. |
40
40
  | `kryd ai add \| remove \| status [project]` | Give the project an authenticated EU AI-gateway endpoint (injected on the next deploy), take it away again, or show what it has. |
41
+ | `kryd workflow add \| remove \| status [project]` | Give the project its own durable-workflow tenant — `HATCHET_CLIENT_TOKEN` is injected on the next deploy — revoke that token again, or show whether it has one and when it expires. `remove` keeps the tenant's history, crons and schedules until the project is deleted. |
42
+ | `kryd workflow init --language <typescript\|python\|go>` | Scaffold a workflow worker here and link it as a new project. The worker ships with the small HTTP health listener Kryd's deploy gate needs — without it a deploy is torn down with "nothing listening yet" — plus a `kryd.json` that tells `kryd init` what this project is. `--no-link` writes the files only. |
41
43
 
42
44
  Run `kryd <command> --help` for options. Every project-scoped command accepts an explicit `<project>` id, or resolves it from the `.kryd` link (walking up from the current directory, git-style).
43
45
 
package/dist/index.js CHANGED
@@ -14729,6 +14729,7 @@ var MANAGED_ENV_VAR_NAMES = [
14729
14729
  "DATABASE_URL",
14730
14730
  "DEPLOY_TOKEN",
14731
14731
  "FORGE_PASSWORD",
14732
+ "HATCHET_CLIENT_TOKEN",
14732
14733
  "PUSH_TOKEN",
14733
14734
  "WEBHOOK_SECRET"
14734
14735
  ];
@@ -14894,8 +14895,14 @@ function isTerminalResourceStatus(status) {
14894
14895
  }
14895
14896
 
14896
14897
  // src/index.ts
14897
- import { existsSync as existsSync3, realpathSync } from "node:fs";
14898
- import { isAbsolute, relative, resolve } from "node:path";
14898
+ import {
14899
+ existsSync as existsSync3,
14900
+ mkdirSync as mkdirSync2,
14901
+ realpathSync,
14902
+ rmSync as rmSync2,
14903
+ writeFileSync as writeFileSync2
14904
+ } from "node:fs";
14905
+ import { basename as basename2, dirname as dirname2, isAbsolute, join as join3, relative, resolve as resolve2 } from "node:path";
14899
14906
  import { pathToFileURL } from "node:url";
14900
14907
 
14901
14908
  // src/config.ts
@@ -15017,6 +15024,11 @@ function ensureIgnored(cwd, entry, aliases = []) {
15017
15024
  function ensureKrydIgnored(cwd) {
15018
15025
  ensureIgnored(cwd, `${PROJECT_LINK_DIR}/`, [PROJECT_LINK_DIR]);
15019
15026
  }
15027
+ function directoryIsLinked(cwd) {
15028
+ const path = join(cwd, PROJECT_LINK_DIR, PROJECT_LINK_FILE);
15029
+ if (!existsSync(path)) return null;
15030
+ return loadProjectLink(cwd);
15031
+ }
15020
15032
  function writeSecretFile(path, contents) {
15021
15033
  mkdirSync(dirname(path), { recursive: true });
15022
15034
  writeFileSync(path, contents, { mode: 384 });
@@ -15057,7 +15069,7 @@ function browserLogin(dashboardUrl, opts = {}) {
15057
15069
  const state = randomUUID();
15058
15070
  const timeoutMs = opts.timeoutMs ?? 12e4;
15059
15071
  const shouldOpen = opts.open ?? true;
15060
- return new Promise((resolve2, reject) => {
15072
+ return new Promise((resolve3, reject) => {
15061
15073
  let settled = false;
15062
15074
  const settle = (fn) => {
15063
15075
  if (settled) return;
@@ -15089,7 +15101,7 @@ function browserLogin(dashboardUrl, opts = {}) {
15089
15101
  res.end(SUCCESS_HTML, () => {
15090
15102
  settle(() => {
15091
15103
  shutdown();
15092
- resolve2({ token, ...apiUrl ? { apiUrl } : {} });
15104
+ resolve3({ token, ...apiUrl ? { apiUrl } : {} });
15093
15105
  });
15094
15106
  });
15095
15107
  });
@@ -15296,6 +15308,57 @@ async function removeAi(apiUrl, token, projectId) {
15296
15308
  );
15297
15309
  }
15298
15310
  }
15311
+ function parseWorkflowStatus(body, label) {
15312
+ const parsed = body;
15313
+ if (!parsed || typeof parsed.enabled !== "boolean" || typeof parsed.tokenExpiresAt !== "string" && parsed.tokenExpiresAt !== null) {
15314
+ throw new ApiError(`The API returned an unexpected ${label} response.`);
15315
+ }
15316
+ return { enabled: parsed.enabled, tokenExpiresAt: parsed.tokenExpiresAt };
15317
+ }
15318
+ async function addWorkflows(apiUrl, token, input) {
15319
+ const res = await fetch(`${apiUrl}/workflows`, {
15320
+ method: "POST",
15321
+ headers: {
15322
+ "content-type": "application/json",
15323
+ authorization: `Bearer ${token}`
15324
+ },
15325
+ body: JSON.stringify({ projectId: input.projectId })
15326
+ });
15327
+ if (!res.ok) {
15328
+ throw new ApiError(
15329
+ `Workflow add failed (${res.status})`,
15330
+ await parseEnvelope(res),
15331
+ res.status
15332
+ );
15333
+ }
15334
+ return parseWorkflowStatus(await res.json().catch(() => void 0), "workflow-add");
15335
+ }
15336
+ async function getWorkflowStatus(apiUrl, token, projectId) {
15337
+ const res = await fetch(`${apiUrl}/workflows/${encodeURIComponent(projectId)}`, {
15338
+ headers: { authorization: `Bearer ${token}` }
15339
+ });
15340
+ if (!res.ok) {
15341
+ throw new ApiError(
15342
+ `Workflow status check failed (${res.status})`,
15343
+ await parseEnvelope(res),
15344
+ res.status
15345
+ );
15346
+ }
15347
+ return parseWorkflowStatus(await res.json().catch(() => void 0), "workflow-status");
15348
+ }
15349
+ async function removeWorkflows(apiUrl, token, projectId) {
15350
+ const res = await fetch(`${apiUrl}/workflows/${encodeURIComponent(projectId)}`, {
15351
+ method: "DELETE",
15352
+ headers: { authorization: `Bearer ${token}` }
15353
+ });
15354
+ if (!res.ok) {
15355
+ throw new ApiError(
15356
+ `Workflow removal failed (${res.status})`,
15357
+ await parseEnvelope(res),
15358
+ res.status
15359
+ );
15360
+ }
15361
+ }
15299
15362
  async function fetchResourceStatus(apiUrl, token, path) {
15300
15363
  const res = await fetch(`${apiUrl}${path}`, {
15301
15364
  headers: { authorization: `Bearer ${token}` }
@@ -15712,14 +15775,14 @@ async function* readSseFrames(body) {
15712
15775
  }
15713
15776
  function abortableSleep(ms, signal) {
15714
15777
  if (signal?.aborted) return Promise.resolve();
15715
- return new Promise((resolve2) => {
15778
+ return new Promise((resolve3) => {
15716
15779
  const onAbort = () => {
15717
15780
  clearTimeout(timer);
15718
- resolve2();
15781
+ resolve3();
15719
15782
  };
15720
15783
  const timer = setTimeout(() => {
15721
15784
  signal?.removeEventListener("abort", onAbort);
15722
- resolve2();
15785
+ resolve3();
15723
15786
  }, ms);
15724
15787
  signal?.addEventListener("abort", onAbort, { once: true });
15725
15788
  });
@@ -15821,11 +15884,14 @@ var FRAMEWORKS = [
15821
15884
  "react-router",
15822
15885
  "nextjs",
15823
15886
  "vite-spa",
15824
- "node"
15887
+ "node",
15888
+ "workflow"
15825
15889
  ];
15826
15890
  function isFramework(value) {
15827
15891
  return FRAMEWORKS.includes(value);
15828
15892
  }
15893
+ var DECLARATION_FILE = "kryd.json";
15894
+ var DECLARATION_KIND_WORKFLOW = "workflow";
15829
15895
  var VITE_META_FRAMEWORKS = [
15830
15896
  "@sveltejs/kit",
15831
15897
  "astro",
@@ -15867,7 +15933,22 @@ function readPackageJson(cwd) {
15867
15933
  return null;
15868
15934
  }
15869
15935
  }
15936
+ function declaredKind(cwd) {
15937
+ try {
15938
+ const parsed = JSON.parse(
15939
+ readFileSync2(join2(cwd, DECLARATION_FILE), "utf8")
15940
+ );
15941
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
15942
+ return null;
15943
+ }
15944
+ const kind = parsed.kind;
15945
+ return typeof kind === "string" ? kind : null;
15946
+ } catch {
15947
+ return null;
15948
+ }
15949
+ }
15870
15950
  function frameworkFrom(pkg, cwd) {
15951
+ if (declaredKind(cwd) === DECLARATION_KIND_WORKFLOW) return "workflow";
15871
15952
  const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
15872
15953
  const has = (name) => name in deps;
15873
15954
  if (has("next")) return "nextjs";
@@ -15889,6 +15970,528 @@ function inspectProject(cwd) {
15889
15970
  return { framework: frameworkFrom(pkg, cwd), name: nameFrom(pkg, cwd) };
15890
15971
  }
15891
15972
 
15973
+ // src/scaffold/typescript.ts
15974
+ var TYPESCRIPT_TEMPLATE = {
15975
+ files: (workerName) => [
15976
+ {
15977
+ path: "package.json",
15978
+ contents: `${JSON.stringify(
15979
+ {
15980
+ name: workerName,
15981
+ private: true,
15982
+ version: "0.1.0",
15983
+ scripts: {
15984
+ build: "tsc",
15985
+ start: "node dist/worker.js"
15986
+ },
15987
+ engines: { node: ">=20" },
15988
+ dependencies: {
15989
+ "@hatchet-dev/typescript-sdk": "^1.33.0"
15990
+ },
15991
+ devDependencies: {
15992
+ "@types/node": "^22.10.2",
15993
+ typescript: "^5.7.2"
15994
+ }
15995
+ },
15996
+ null,
15997
+ 2
15998
+ )}
15999
+ `
16000
+ },
16001
+ {
16002
+ path: "tsconfig.json",
16003
+ contents: `${JSON.stringify(
16004
+ {
16005
+ compilerOptions: {
16006
+ // CommonJS because the SDK is: it ships no "exports" map and no "type": "module".
16007
+ module: "commonjs",
16008
+ moduleResolution: "node",
16009
+ target: "es2022",
16010
+ lib: ["es2022"],
16011
+ outDir: "dist",
16012
+ rootDir: "src",
16013
+ strict: true,
16014
+ esModuleInterop: true,
16015
+ skipLibCheck: true
16016
+ },
16017
+ include: ["src"]
16018
+ },
16019
+ null,
16020
+ 2
16021
+ )}
16022
+ `
16023
+ },
16024
+ {
16025
+ path: "src/hatchet.ts",
16026
+ contents: `import { HatchetClient } from "@hatchet-dev/typescript-sdk/v1";
16027
+
16028
+ /**
16029
+ * Reads HATCHET_CLIENT_TOKEN from the environment and needs nothing else: the token carries the
16030
+ * engine's addresses. Kryd injects it on every deploy once you have run \`kryd workflow add\`.
16031
+ *
16032
+ * It lives in its own file so tasks and the worker can share one client without importing each
16033
+ * other in a circle.
16034
+ */
16035
+ export const hatchet = HatchetClient.init();
16036
+ `
16037
+ },
16038
+ {
16039
+ path: "src/tasks.ts",
16040
+ contents: `import { hatchet } from "./hatchet";
16041
+
16042
+ /**
16043
+ * One task, to prove the wiring end to end. Add your own beside it and list them in
16044
+ * \`src/worker.ts\` \u2014 everything about how a task runs (retries, timeouts, concurrency, crons,
16045
+ * DAGs) belongs to the engine's SDK, not to Kryd.
16046
+ */
16047
+ export const greet = hatchet.task({
16048
+ name: "greet",
16049
+ fn: (input: { name?: string }) => ({
16050
+ greeting: \`Hello, \${input.name ?? "world"}\`,
16051
+ }),
16052
+ });
16053
+ `
16054
+ },
16055
+ {
16056
+ path: "src/worker.ts",
16057
+ contents: `import { createServer } from "node:http";
16058
+
16059
+ const WORKER_NAME = ${JSON.stringify(workerName)};
16060
+
16061
+ /*
16062
+ * The health listener, and the one piece of this file that is about Kryd rather than about workflows.
16063
+ *
16064
+ * A worker holds an outbound gRPC session and listens on no port of its own, but Kryd decides a
16065
+ * deploy is live by asking GET / for a 2xx. Without this the deploy is torn down with
16066
+ * "connection refused (nothing listening yet)".
16067
+ *
16068
+ * \u{1F6A8} It answers 503 until the engine has registered the worker, on purpose. Answering 200 straight
16069
+ * away would mark the deploy live a moment before a failed registration crashes the process \u2014 which
16070
+ * is exactly what happened while this was being measured.
16071
+ */
16072
+ let registered = false;
16073
+
16074
+ createServer((_req, res) => {
16075
+ res.writeHead(registered ? 200 : 503, { "content-type": "application/json" });
16076
+ res.end(
16077
+ JSON.stringify({
16078
+ worker: WORKER_NAME,
16079
+ status: registered ? "registered" : "starting",
16080
+ }),
16081
+ );
16082
+ }).listen(Number(process.env.PORT ?? 8080), "0.0.0.0");
16083
+
16084
+ async function main(): Promise<void> {
16085
+ // Imported here rather than at the top of the file, and that is deliberate: creating the client
16086
+ // reads and decodes HATCHET_CLIENT_TOKEN, and a token it cannot parse makes it throw during
16087
+ // module evaluation \u2014 before the listener above ever binds. The deploy would then fail with
16088
+ // "connection refused (nothing listening yet)", which is the one message this whole scaffold
16089
+ // exists to stop you seeing. Binding first means a bad token shows up as a 503 plus a readable
16090
+ // error in your deploy log.
16091
+ const { hatchet } = await import("./hatchet");
16092
+ const { greet } = await import("./tasks");
16093
+
16094
+ const worker = await hatchet.worker(WORKER_NAME, { workflows: [greet] });
16095
+
16096
+ // start() resolves when the worker stops, so it is not awaited here; waitUntilReady() flips the
16097
+ // health flag once the engine has the worker. A failed registration rejects \`running\` and the
16098
+ // process exits non-zero, which fails the deploy loudly instead of going live and crash-looping.
16099
+ const running = worker.start();
16100
+ void worker.waitUntilReady().then(
16101
+ () => {
16102
+ registered = true;
16103
+ },
16104
+ () => {
16105
+ // Stays 503. The deploy gate reports it; there is nothing useful to do here.
16106
+ },
16107
+ );
16108
+ await running;
16109
+ }
16110
+
16111
+ main().catch((err: unknown) => {
16112
+ console.error("[worker] stopped:", err);
16113
+ process.exit(1);
16114
+ });
16115
+ `
16116
+ },
16117
+ {
16118
+ path: ".gitignore",
16119
+ contents: `node_modules/
16120
+ dist/
16121
+ .env
16122
+ .env.local
16123
+ `
16124
+ },
16125
+ {
16126
+ path: "README.md",
16127
+ contents: `# ${workerName}
16128
+
16129
+ A Hatchet workflow worker, ready to deploy on Kryd.
16130
+
16131
+ - \`src/tasks.ts\` holds your tasks. \`src/worker.ts\` starts the worker and serves the health
16132
+ endpoint Kryd's deploy gate needs \u2014 keep that listener, or your deploys will be torn down.
16133
+ - **\`HATCHET_CLIENT_TOKEN\` only exists inside a deployed container.** Kryd never prints it, so you
16134
+ cannot run this worker against your tenant from your laptop today.
16135
+ - Deploy it: \`kryd workflow add\` once, then \`kryd push\`.
16136
+
16137
+ Full docs: https://docs.kryd.eu/docs/workflows
16138
+ `
16139
+ }
16140
+ ]
16141
+ };
16142
+
16143
+ // src/scaffold/python.ts
16144
+ var PYTHON_TEMPLATE = {
16145
+ files: (workerName) => [
16146
+ {
16147
+ path: "requirements.txt",
16148
+ contents: `hatchet-sdk>=1.40.1
16149
+ `
16150
+ },
16151
+ {
16152
+ path: "tasks.py",
16153
+ contents: `from hatchet_client import hatchet
16154
+ from hatchet_sdk import Context, EmptyModel
16155
+
16156
+
16157
+ # One task, to prove the wiring end to end. Add your own beside it and list them in main.py \u2014
16158
+ # everything about how a task runs (retries, timeouts, concurrency, crons, DAGs) belongs to the
16159
+ # engine's SDK, not to Kryd.
16160
+ @hatchet.task(name="greet")
16161
+ def greet(input: EmptyModel, ctx: Context) -> dict[str, str]:
16162
+ return {"greeting": "Hello, world"}
16163
+ `
16164
+ },
16165
+ {
16166
+ path: "hatchet_client.py",
16167
+ contents: `from hatchet_sdk import Hatchet
16168
+
16169
+ # Reads HATCHET_CLIENT_TOKEN from the environment and needs nothing else: the token carries the
16170
+ # engine's addresses. Kryd injects it on every deploy once you have run \`kryd workflow add\`.
16171
+ #
16172
+ # It lives in its own module so tasks and the worker can share one client without importing each
16173
+ # other in a circle.
16174
+ hatchet = Hatchet()
16175
+ `
16176
+ },
16177
+ {
16178
+ path: "main.py",
16179
+ contents: `"""Worker entry point.
16180
+
16181
+ \u{1F6A8} This file must stay at the project root and keep this name: Kryd's builder derives the start
16182
+ command by finding main.py here. Move it into a package and the deploy has no start command at all.
16183
+ """
16184
+
16185
+ import json
16186
+ import os
16187
+ import threading
16188
+ import time
16189
+ from http.server import BaseHTTPRequestHandler, HTTPServer
16190
+
16191
+ WORKER_NAME = ${JSON.stringify(workerName)}
16192
+
16193
+ # The health listener, and the one piece of this file that is about Kryd rather than about workflows.
16194
+ #
16195
+ # A worker holds an outbound gRPC session and listens on no port of its own, but Kryd decides a
16196
+ # deploy is live by asking GET / for a 2xx. Without this the deploy is torn down with
16197
+ # "connection refused (nothing listening yet)".
16198
+ #
16199
+ # \u{1F6A8} It answers 503 until the engine has registered the worker, on purpose. Answering 200 straight
16200
+ # away would mark the deploy live a moment before a failed registration crashes the process.
16201
+ _registered = threading.Event()
16202
+
16203
+
16204
+ class _Health(BaseHTTPRequestHandler):
16205
+ def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler's spelling
16206
+ ready = _registered.is_set()
16207
+ body = json.dumps(
16208
+ {"worker": WORKER_NAME, "status": "registered" if ready else "starting"}
16209
+ ).encode()
16210
+ self.send_response(200 if ready else 503)
16211
+ self.send_header("content-type", "application/json")
16212
+ self.send_header("content-length", str(len(body)))
16213
+ self.end_headers()
16214
+ self.wfile.write(body)
16215
+
16216
+ def log_message(self, *args: object) -> None:
16217
+ # The default handler writes every request to stderr, which would drown the worker's own
16218
+ # logs: the deploy gate polls this endpoint every two seconds.
16219
+ pass
16220
+
16221
+
16222
+ def _serve_health() -> None:
16223
+ port = int(os.environ.get("PORT", "8080"))
16224
+ HTTPServer(("0.0.0.0", port), _Health).serve_forever()
16225
+
16226
+
16227
+ def _watch_registration(worker: object) -> None:
16228
+ # worker.start() blocks, so readiness is observed from a thread. The status enum is compared by
16229
+ # name rather than imported: it lives at a private-ish path in the SDK and this keeps the
16230
+ # scaffold working across SDK reshuffles.
16231
+ #
16232
+ # \u{1F6A8} If the SDK ever renames or drops .status, this loop would spin forever, / would answer 503
16233
+ # forever, and the deploy would be failed by the health gate after three minutes with nothing in
16234
+ # the log to explain it. Say so once instead: a deploy that fails is fine, a deploy that fails
16235
+ # silently is not.
16236
+ if not hasattr(worker, "status"):
16237
+ print(
16238
+ "[worker] this SDK build has no 'status' attribute, so readiness cannot be observed; "
16239
+ "the health endpoint will stay 503 and Kryd will fail the deploy.",
16240
+ flush=True,
16241
+ )
16242
+ return
16243
+
16244
+ while not _registered.is_set():
16245
+ if getattr(getattr(worker, "status", None), "name", "") == "HEALTHY":
16246
+ _registered.set()
16247
+ return
16248
+ time.sleep(0.2)
16249
+
16250
+
16251
+ def main() -> None:
16252
+ threading.Thread(target=_serve_health, daemon=True).start()
16253
+
16254
+ # Imported here rather than at the top of the file, and that is deliberate: creating the client
16255
+ # reads and decodes HATCHET_CLIENT_TOKEN, and a token it cannot parse raises during import \u2014
16256
+ # before the listener above ever binds. The deploy would then fail with "connection refused
16257
+ # (nothing listening yet)", which is the one message this whole scaffold exists to stop you
16258
+ # seeing. Binding first means a bad token shows up as a 503 plus a readable error in your log.
16259
+ from hatchet_client import hatchet
16260
+ from tasks import greet
16261
+
16262
+ worker = hatchet.worker(WORKER_NAME, workflows=[greet])
16263
+ threading.Thread(target=_watch_registration, args=(worker,), daemon=True).start()
16264
+ worker.start()
16265
+
16266
+
16267
+ if __name__ == "__main__":
16268
+ main()
16269
+ `
16270
+ },
16271
+ {
16272
+ path: ".gitignore",
16273
+ contents: `__pycache__/
16274
+ *.py[cod]
16275
+ .venv/
16276
+ venv/
16277
+ .env
16278
+ .env.local
16279
+ `
16280
+ },
16281
+ {
16282
+ path: "README.md",
16283
+ contents: `# ${workerName}
16284
+
16285
+ A Hatchet workflow worker, ready to deploy on Kryd.
16286
+
16287
+ - \`tasks.py\` holds your tasks. \`main.py\` starts the worker and serves the health endpoint Kryd's
16288
+ deploy gate needs \u2014 keep that listener, and keep \`main.py\` at the root under that name, or your
16289
+ deploys will fail.
16290
+ - **\`HATCHET_CLIENT_TOKEN\` only exists inside a deployed container.** Kryd never prints it, so you
16291
+ cannot run this worker against your tenant from your laptop today.
16292
+ - Deploy it: \`kryd workflow add\` once, then \`kryd push\`.
16293
+
16294
+ Full docs: https://docs.kryd.eu/docs/workflows
16295
+ `
16296
+ }
16297
+ ]
16298
+ };
16299
+
16300
+ // src/scaffold/go.ts
16301
+ var GO_TEMPLATE = {
16302
+ firstCommand: "go mod tidy",
16303
+ files: (workerName) => [
16304
+ {
16305
+ path: "go.mod",
16306
+ contents: `module kryd.local/${workerName}
16307
+
16308
+ go 1.26
16309
+
16310
+ toolchain go1.26.0
16311
+
16312
+ require github.com/hatchet-dev/hatchet v0.106.10
16313
+ `
16314
+ },
16315
+ {
16316
+ path: "tasks.go",
16317
+ contents: `package main
16318
+
16319
+ import (
16320
+ hatchet "github.com/hatchet-dev/hatchet/sdks/go"
16321
+ )
16322
+
16323
+ // GreetInput is what a run is triggered with.
16324
+ type GreetInput struct {
16325
+ Name string \`json:"name"\`
16326
+ }
16327
+
16328
+ // GreetOutput is what the task returns.
16329
+ type GreetOutput struct {
16330
+ Greeting string \`json:"greeting"\`
16331
+ }
16332
+
16333
+ // Greet is one task, to prove the wiring end to end. Add your own beside it and register them in
16334
+ // main.go \u2014 everything about how a task runs (retries, timeouts, concurrency, crons, DAGs) belongs
16335
+ // to the engine's SDK, not to Kryd.
16336
+ func Greet(c *hatchet.Client) *hatchet.StandaloneTask {
16337
+ return c.NewStandaloneTask("greet", func(ctx hatchet.Context, input GreetInput) (GreetOutput, error) {
16338
+ name := input.Name
16339
+ if name == "" {
16340
+ name = "world"
16341
+ }
16342
+ return GreetOutput{Greeting: "Hello, " + name}, nil
16343
+ })
16344
+ }
16345
+ `
16346
+ },
16347
+ {
16348
+ path: "main.go",
16349
+ contents: `// Worker entry point.
16350
+ //
16351
+ // \u{1F6A8} This file must stay at the project root: Kryd's builder compiles the root package when one
16352
+ // exists, and otherwise picks the first cmd/* directory alphabetically \u2014 which is how a trigger
16353
+ // script ends up deployed instead of the worker.
16354
+ package main
16355
+
16356
+ import (
16357
+ "encoding/json"
16358
+ "log"
16359
+ "net/http"
16360
+ "os"
16361
+ "sync/atomic"
16362
+
16363
+ "github.com/hatchet-dev/hatchet/pkg/cmdutils"
16364
+ hatchet "github.com/hatchet-dev/hatchet/sdks/go"
16365
+ )
16366
+
16367
+ const workerName = ${JSON.stringify(workerName)}
16368
+
16369
+ // The health listener, and the one piece of this file that is about Kryd rather than about
16370
+ // workflows.
16371
+ //
16372
+ // A worker holds an outbound gRPC session and listens on no port of its own, but Kryd decides a
16373
+ // deploy is live by asking GET / for a 2xx. Without this the deploy is torn down with
16374
+ // "connection refused (nothing listening yet)".
16375
+ //
16376
+ // \u{1F6A8} It answers 503 until the engine has registered the worker, on purpose. Answering 200 straight
16377
+ // away would mark the deploy live a moment before a failed registration exits the process.
16378
+ var registered atomic.Bool
16379
+
16380
+ func health(w http.ResponseWriter, _ *http.Request) {
16381
+ ready := registered.Load()
16382
+ status := "starting"
16383
+ if ready {
16384
+ status = "registered"
16385
+ }
16386
+ w.Header().Set("content-type", "application/json")
16387
+ if ready {
16388
+ w.WriteHeader(http.StatusOK)
16389
+ } else {
16390
+ w.WriteHeader(http.StatusServiceUnavailable)
16391
+ }
16392
+ _ = json.NewEncoder(w).Encode(map[string]string{"worker": workerName, "status": status})
16393
+ }
16394
+
16395
+ func main() {
16396
+ port := os.Getenv("PORT")
16397
+ if port == "" {
16398
+ port = "8080"
16399
+ }
16400
+
16401
+ mux := http.NewServeMux()
16402
+ mux.HandleFunc("/", health)
16403
+ // Served before the worker connects, so the gate gets a 503 rather than a refused connection
16404
+ // while registration is still in flight.
16405
+ go func() {
16406
+ if err := http.ListenAndServe(":"+port, mux); err != nil {
16407
+ log.Fatalf("health listener stopped: %v", err)
16408
+ }
16409
+ }()
16410
+
16411
+ // Reads HATCHET_CLIENT_TOKEN from the environment and needs nothing else: the token carries the
16412
+ // engine's addresses. Kryd injects it on every deploy once you have run \`kryd workflow add\`.
16413
+ client, err := hatchet.NewClient()
16414
+ if err != nil {
16415
+ log.Fatalf("could not create the Hatchet client: %v", err)
16416
+ }
16417
+
16418
+ worker, err := client.NewWorker(workerName, hatchet.WithWorkflows(Greet(client)))
16419
+ if err != nil {
16420
+ log.Fatalf("could not create the worker: %v", err)
16421
+ }
16422
+
16423
+ // Start returns once the engine has registered the worker, so the flag flips exactly then.
16424
+ cleanup, err := worker.Start()
16425
+ if err != nil {
16426
+ log.Fatalf("could not start the worker: %v", err)
16427
+ }
16428
+ registered.Store(true)
16429
+
16430
+ ctx, cancel := cmdutils.NewInterruptContext()
16431
+ defer cancel()
16432
+ <-ctx.Done()
16433
+
16434
+ if err := cleanup(); err != nil {
16435
+ log.Fatalf("worker shutdown failed: %v", err)
16436
+ }
16437
+ }
16438
+ `
16439
+ },
16440
+ {
16441
+ path: ".gitignore",
16442
+ contents: `/${workerName}
16443
+ *.exe
16444
+ .env
16445
+ .env.local
16446
+ `
16447
+ },
16448
+ {
16449
+ path: "README.md",
16450
+ contents: `# ${workerName}
16451
+
16452
+ A Hatchet workflow worker, ready to deploy on Kryd.
16453
+
16454
+ - Run \`go mod tidy\` once before your first push \u2014 no \`go.sum\` ships with the scaffold.
16455
+ - \`tasks.go\` holds your tasks. \`main.go\` starts the worker and serves the health endpoint Kryd's
16456
+ deploy gate needs \u2014 keep that listener, and keep \`main.go\` at the root, or your deploys will fail.
16457
+ - **\`HATCHET_CLIENT_TOKEN\` only exists inside a deployed container.** Kryd never prints it, so you
16458
+ cannot run this worker against your tenant from your laptop today.
16459
+ - Deploy it: \`kryd workflow add\` once, then \`kryd push\`.
16460
+
16461
+ Full docs: https://docs.kryd.eu/docs/workflows
16462
+ `
16463
+ }
16464
+ ]
16465
+ };
16466
+
16467
+ // src/scaffold/index.ts
16468
+ var SCAFFOLD_LANGUAGES = ["typescript", "python", "go"];
16469
+ function isScaffoldLanguage(value) {
16470
+ return SCAFFOLD_LANGUAGES.includes(value);
16471
+ }
16472
+ var TEMPLATES = {
16473
+ typescript: TYPESCRIPT_TEMPLATE,
16474
+ python: PYTHON_TEMPLATE,
16475
+ go: GO_TEMPLATE
16476
+ };
16477
+ function declarationFile() {
16478
+ return {
16479
+ path: DECLARATION_FILE,
16480
+ contents: `${JSON.stringify({ kind: DECLARATION_KIND_WORKFLOW }, null, 2)}
16481
+ `
16482
+ };
16483
+ }
16484
+ function scaffoldFiles(language, workerName) {
16485
+ return [...TEMPLATES[language].files(workerName), declarationFile()];
16486
+ }
16487
+ function firstCommand(language) {
16488
+ return TEMPLATES[language].firstCommand;
16489
+ }
16490
+ function workerNameFrom(directoryName) {
16491
+ const slug = directoryName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48);
16492
+ return slug || "kryd-worker";
16493
+ }
16494
+
15892
16495
  // src/progress.ts
15893
16496
  var STEPS = DEPLOY_STATES.filter(
15894
16497
  (s) => !TERMINAL_DEPLOY_STATES.includes(s)
@@ -16219,10 +16822,10 @@ async function promptHidden(question, io) {
16219
16822
  terminal: true
16220
16823
  });
16221
16824
  try {
16222
- return await new Promise((resolve2) => {
16223
- rl.once("SIGINT", () => resolve2(null));
16224
- rl.once("close", () => resolve2(null));
16225
- rl.question("", (answer) => resolve2(answer));
16825
+ return await new Promise((resolve3) => {
16826
+ rl.once("SIGINT", () => resolve3(null));
16827
+ rl.once("close", () => resolve3(null));
16828
+ rl.question("", (answer) => resolve3(answer));
16226
16829
  });
16227
16830
  } finally {
16228
16831
  rl.close();
@@ -16234,10 +16837,10 @@ async function promptHidden(question, io) {
16234
16837
  async function promptLine(question, io) {
16235
16838
  const rl = createInterface({ input: io.input, output: io.output });
16236
16839
  try {
16237
- return await new Promise((resolve2) => {
16238
- rl.once("SIGINT", () => resolve2(null));
16239
- rl.once("close", () => resolve2(null));
16240
- rl.question(question, (answer) => resolve2(answer));
16840
+ return await new Promise((resolve3) => {
16841
+ rl.once("SIGINT", () => resolve3(null));
16842
+ rl.once("close", () => resolve3(null));
16843
+ rl.question(question, (answer) => resolve3(answer));
16241
16844
  });
16242
16845
  } finally {
16243
16846
  rl.close();
@@ -16246,10 +16849,10 @@ async function promptLine(question, io) {
16246
16849
  async function promptConfirm(question, io) {
16247
16850
  const rl = createInterface({ input: io.input, output: io.output });
16248
16851
  try {
16249
- const answer = await new Promise((resolve2) => {
16250
- rl.once("SIGINT", () => resolve2(null));
16251
- rl.once("close", () => resolve2(null));
16252
- rl.question(`${question} [y/N] `, (a) => resolve2(a));
16852
+ const answer = await new Promise((resolve3) => {
16853
+ rl.once("SIGINT", () => resolve3(null));
16854
+ rl.once("close", () => resolve3(null));
16855
+ rl.question(`${question} [y/N] `, (a) => resolve3(a));
16253
16856
  });
16254
16857
  return answer !== null && /^y(es)?$/i.test(answer.trim());
16255
16858
  } finally {
@@ -16259,6 +16862,7 @@ async function promptConfirm(question, io) {
16259
16862
 
16260
16863
  // src/git.ts
16261
16864
  import { execFileSync, spawnSync } from "node:child_process";
16865
+ import { resolve } from "node:path";
16262
16866
  function authenticatedRemoteUrl(cloneUrl, username, token) {
16263
16867
  const prefix = "https://";
16264
16868
  if (!cloneUrl.startsWith(prefix)) return cloneUrl;
@@ -16323,6 +16927,21 @@ function gitAvailable(cwd) {
16323
16927
  if (res.status !== "ok" || res.value !== "true") return { status: "not-a-repo" };
16324
16928
  return { status: "ok" };
16325
16929
  }
16930
+ function initRepo(cwd) {
16931
+ const existing = gitAvailable(cwd);
16932
+ if (existing.status === "no-git") return existing;
16933
+ if (existing.status === "ok") {
16934
+ const top = probe(cwd, ["rev-parse", "--show-toplevel"]);
16935
+ if (top.status === "ok" && resolve(top.value) !== resolve(cwd)) {
16936
+ return { status: "nested", root: resolve(top.value) };
16937
+ }
16938
+ return { status: "already" };
16939
+ }
16940
+ const res = probe(cwd, ["init", "--quiet"]);
16941
+ if (res.status === "no-git") return res;
16942
+ if (res.status !== "ok") return { status: "failed" };
16943
+ return { status: "ok" };
16944
+ }
16326
16945
  function currentBranch(cwd) {
16327
16946
  return probe(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
16328
16947
  }
@@ -16411,12 +17030,31 @@ async function runInit(opts) {
16411
17030
  process.exitCode = 1;
16412
17031
  return;
16413
17032
  }
17033
+ const existingLink = directoryIsLinked(cwd);
17034
+ if (existingLink) {
17035
+ const remote = hasRemote(cwd, "kryd");
17036
+ const remoteConfigured = remote.status === "ok" && remote.value;
17037
+ if (remoteConfigured) {
17038
+ process.stderr.write(
17039
+ `This folder is already linked to ${existingLink.projectId} (.kryd/project.json).
17040
+ Running \`kryd init\` again would create a SECOND project and leave that one orphaned.
17041
+ To deploy it: \`kryd push\`. To start over: \`kryd project rm\` first, or delete .kryd/project.json to unlink this folder.
17042
+ `
17043
+ );
17044
+ process.exitCode = 1;
17045
+ return;
17046
+ }
17047
+ process.stdout.write(
17048
+ `This folder is linked to ${existingLink.projectId} but has no \`kryd\` remote \u2014 finishing that setup.
17049
+ `
17050
+ );
17051
+ }
16414
17052
  const detected = inspectProject(cwd);
16415
17053
  let framework;
16416
17054
  if (opts.framework) {
16417
17055
  if (!isFramework(opts.framework)) {
16418
17056
  process.stderr.write(
16419
- `Unknown framework "${opts.framework}" \u2014 expected react-router, nextjs, vite-spa, or node.
17057
+ `Unknown framework "${opts.framework}" \u2014 expected react-router, nextjs, vite-spa, node, or workflow.
16420
17058
  `
16421
17059
  );
16422
17060
  process.exitCode = 1;
@@ -16426,7 +17064,7 @@ async function runInit(opts) {
16426
17064
  } else {
16427
17065
  if (!detected.framework) {
16428
17066
  process.stderr.write(
16429
- "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"
17067
+ "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"
16430
17068
  );
16431
17069
  process.exitCode = 1;
16432
17070
  return;
@@ -17377,6 +18015,245 @@ async function runAiStatus(opts) {
17377
18015
  reportError(err);
17378
18016
  }
17379
18017
  }
18018
+ function isoDay(iso) {
18019
+ return iso.slice(0, 10);
18020
+ }
18021
+ function daysUntil(iso) {
18022
+ return Math.floor((new Date(iso).getTime() - Date.now()) / 864e5);
18023
+ }
18024
+ var TOKEN_EXPIRY_WARNING_DAYS = 30;
18025
+ async function runWorkflowAdd(opts) {
18026
+ const apiUrl = resolveApiUrl(opts.apiUrl);
18027
+ const token = loadConfig().token;
18028
+ if (!token) {
18029
+ process.stderr.write("Not logged in \u2014 run `kryd login` first.\n");
18030
+ process.exitCode = 1;
18031
+ return;
18032
+ }
18033
+ const project2 = resolveProjectId(opts.project, opts.cwd);
18034
+ if (!project2) {
18035
+ reportNotLinked("kryd workflow add <projectId>");
18036
+ return;
18037
+ }
18038
+ try {
18039
+ const current = await getWorkflowStatus(apiUrl, token, project2);
18040
+ if (current.enabled) {
18041
+ process.stdout.write(
18042
+ `${project2} already has workflows \u2014 HATCHET_CLIENT_TOKEN is injected on deploy` + (current.tokenExpiresAt ? ` and the token expires ${isoDay(current.tokenExpiresAt)}.
18043
+ ` : ".\n") + `Nothing was changed. To rotate the token, run \`kryd workflow remove\` and then \`kryd workflow add\`.
18044
+ `
18045
+ );
18046
+ return;
18047
+ }
18048
+ const status = await addWorkflows(apiUrl, token, { projectId: project2 });
18049
+ process.stdout.write(
18050
+ `Workflows added to ${project2}.
18051
+ HATCHET_CLIENT_TOKEN will be injected on your next deploy (kryd deploy)` + (status.tokenExpiresAt ? `; the token expires ${isoDay(status.tokenExpiresAt)}.
18052
+ ` : ".\n")
18053
+ );
18054
+ } catch (err) {
18055
+ reportError(err);
18056
+ }
18057
+ }
18058
+ async function runWorkflowRemove(opts) {
18059
+ const apiUrl = resolveApiUrl(opts.apiUrl);
18060
+ const token = loadConfig().token;
18061
+ if (!token) {
18062
+ process.stderr.write("Not logged in \u2014 run `kryd login` first.\n");
18063
+ process.exitCode = 1;
18064
+ return;
18065
+ }
18066
+ const project2 = resolveProjectId(opts.project, opts.cwd);
18067
+ if (!project2) {
18068
+ reportNotLinked("kryd workflow remove <projectId>");
18069
+ return;
18070
+ }
18071
+ let current;
18072
+ try {
18073
+ current = await getWorkflowStatus(apiUrl, token, project2);
18074
+ } catch (err) {
18075
+ reportError(err);
18076
+ return;
18077
+ }
18078
+ if (!current.enabled) {
18079
+ process.stdout.write(`${project2}: no workflow token \u2014 nothing to remove.
18080
+ `);
18081
+ return;
18082
+ }
18083
+ const proceed = await confirmResourceRemoval({
18084
+ yes: opts.yes,
18085
+ io: opts.io,
18086
+ subject: `workflows from ${project2}`,
18087
+ question: `Remove workflows from ${project2}? The token is revoked \u2014 a running worker loses its connection within seconds \u2014 and HATCHET_CLIENT_TOKEN stops being injected from your next deploy. Workflow history, crons and schedules stay until the project is deleted.`,
18088
+ declined: `Left the workflows on ${project2} in place.
18089
+ `
18090
+ });
18091
+ if (!proceed) return;
18092
+ try {
18093
+ await removeWorkflows(apiUrl, token, project2);
18094
+ process.stdout.write(
18095
+ `Workflows removed from ${project2}: the token is revoked (the engine refuses it within seconds; the HTTP API within a minute) and HATCHET_CLIENT_TOKEN stops being injected from your next deploy (kryd deploy).
18096
+ Workflow history, crons and schedules stay until the project is deleted.
18097
+ `
18098
+ );
18099
+ } catch (err) {
18100
+ reportError(err);
18101
+ }
18102
+ }
18103
+ async function runWorkflowStatus(opts) {
18104
+ const apiUrl = resolveApiUrl(opts.apiUrl);
18105
+ const token = loadConfig().token;
18106
+ if (!token) {
18107
+ process.stderr.write("Not logged in \u2014 run `kryd login` first.\n");
18108
+ process.exitCode = 1;
18109
+ return;
18110
+ }
18111
+ const project2 = resolveProjectId(opts.project, opts.cwd);
18112
+ if (!project2) {
18113
+ reportNotLinked("kryd workflow status <projectId>");
18114
+ return;
18115
+ }
18116
+ try {
18117
+ const status = await getWorkflowStatus(apiUrl, token, project2);
18118
+ if (!status.enabled) {
18119
+ process.stdout.write(`${project2}: no workflows.
18120
+ `);
18121
+ return;
18122
+ }
18123
+ if (!status.tokenExpiresAt) {
18124
+ process.stdout.write(`${project2}: workflows enabled.
18125
+ `);
18126
+ return;
18127
+ }
18128
+ const days = daysUntil(status.tokenExpiresAt);
18129
+ process.stdout.write(
18130
+ `${project2}: workflows enabled \u2014 the token expires ${isoDay(status.tokenExpiresAt)}.
18131
+ `
18132
+ );
18133
+ if (days <= TOKEN_EXPIRY_WARNING_DAYS) {
18134
+ process.stdout.write(
18135
+ (days < 0 ? `\u26A0\uFE0F The token has EXPIRED; your worker cannot connect.` : `\u26A0\uFE0F The token expires in ${days} day${days === 1 ? "" : "s"}.`) + ` Rotate it: \`kryd workflow remove\`, then \`kryd workflow add\`, then deploy.
18136
+ `
18137
+ );
18138
+ }
18139
+ } catch (err) {
18140
+ reportError(err);
18141
+ }
18142
+ }
18143
+ async function runWorkflowInit(opts) {
18144
+ const cwd = opts.cwd ?? process.cwd();
18145
+ if (!opts.language) {
18146
+ process.stderr.write(
18147
+ `Pass --language <${SCAFFOLD_LANGUAGES.join("|")}>.
18148
+ `
18149
+ );
18150
+ process.exitCode = 1;
18151
+ return;
18152
+ }
18153
+ if (!isScaffoldLanguage(opts.language)) {
18154
+ process.stderr.write(
18155
+ `Unknown language "${opts.language}" \u2014 expected ${SCAFFOLD_LANGUAGES.join(", ")}.
18156
+ `
18157
+ );
18158
+ process.exitCode = 1;
18159
+ return;
18160
+ }
18161
+ const language = opts.language;
18162
+ const target = opts.dir ? resolve2(cwd, opts.dir) : cwd;
18163
+ const workerName = workerNameFrom(opts.name ?? basename2(target));
18164
+ const files = scaffoldFiles(language, workerName);
18165
+ const clashes = files.map((f) => f.path).filter((p) => existsSync3(join3(target, p)));
18166
+ if (clashes.length > 0) {
18167
+ process.stderr.write(
18168
+ `Refusing to overwrite: ${clashes.join(", ")}.
18169
+ Nothing was written. Run this in an empty directory, or pass --dir <path>.
18170
+ `
18171
+ );
18172
+ process.exitCode = 1;
18173
+ return;
18174
+ }
18175
+ const written = [];
18176
+ try {
18177
+ for (const file2 of files) {
18178
+ const full = join3(target, file2.path);
18179
+ mkdirSync2(dirname2(full), { recursive: true });
18180
+ writeFileSync2(full, file2.contents);
18181
+ written.push(full);
18182
+ }
18183
+ } catch (err) {
18184
+ for (const path of written.reverse()) {
18185
+ try {
18186
+ rmSync2(path);
18187
+ } catch {
18188
+ }
18189
+ }
18190
+ process.stderr.write(
18191
+ `Could not write the scaffold (${err instanceof Error ? err.message : String(err)}).
18192
+ Nothing was left behind.
18193
+ `
18194
+ );
18195
+ process.exitCode = 1;
18196
+ return;
18197
+ }
18198
+ const where = opts.dir ? `${opts.dir}/` : "";
18199
+ process.stdout.write(
18200
+ `Scaffolded a ${language} workflow worker${opts.dir ? ` in ${opts.dir}` : ""}:
18201
+ ` + files.map((f) => ` ${where}${f.path}
18202
+ `).join("") + "\n"
18203
+ );
18204
+ const repo = initRepo(target);
18205
+ if (repo.status === "nested") {
18206
+ process.stderr.write(
18207
+ `That directory is inside the git repository at ${repo.root}.
18208
+ A worker has to be its own repository: Kryd builds one app per repository, and git would
18209
+ write the deploy remote into the enclosing repo, so \`kryd push\` would push that tree instead.
18210
+ The files above were written \u2014 move them to a directory of their own, or run this outside that repository.
18211
+ `
18212
+ );
18213
+ process.exitCode = 1;
18214
+ return;
18215
+ }
18216
+ const next = [];
18217
+ if (opts.dir) next.push(`cd ${opts.dir}`);
18218
+ const first = firstCommand(language);
18219
+ if (first) next.push(first);
18220
+ next.push('git add . && git commit -m "scaffold"');
18221
+ if (opts.link === false) {
18222
+ next.push("kryd init", "kryd workflow add", "kryd push");
18223
+ process.stdout.write(
18224
+ `Next: ${next.map((c) => `\`${c}\``).join(", then ")}.
18225
+ HATCHET_CLIENT_TOKEN is injected into your container on deploy; it is never available locally.
18226
+ `
18227
+ );
18228
+ return;
18229
+ }
18230
+ if (repo.status === "failed") {
18231
+ process.stderr.write(
18232
+ "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"
18233
+ );
18234
+ process.exitCode = 1;
18235
+ return;
18236
+ }
18237
+ process.exitCode = 0;
18238
+ await runInit({
18239
+ cwd: target,
18240
+ ...opts.name ? { name: opts.name } : {},
18241
+ ...opts.apiUrl ? { apiUrl: opts.apiUrl } : {}
18242
+ });
18243
+ if (process.exitCode) {
18244
+ process.stderr.write(
18245
+ "\nThe files above were written; only the link failed. Fix the problem above and run `kryd init` here.\n"
18246
+ );
18247
+ return;
18248
+ }
18249
+ next.push("kryd workflow add", "kryd push");
18250
+ process.stdout.write(
18251
+ `
18252
+ Next: ${next.map((c) => `\`${c}\``).join(", then ")}.
18253
+ HATCHET_CLIENT_TOKEN is injected into your container on deploy; it is never available locally.
18254
+ `
18255
+ );
18256
+ }
17380
18257
  async function runStorageRemove(opts) {
17381
18258
  const apiUrl = resolveApiUrl(opts.apiUrl);
17382
18259
  const token = loadConfig().token;
@@ -17588,7 +18465,7 @@ async function runEnvPull(opts) {
17588
18465
  const cwd = opts.cwd ?? process.cwd();
17589
18466
  const root = findProjectLinkDir(cwd) ?? cwd;
17590
18467
  const outRel = opts.out ?? ".env.kryd";
17591
- const outPath = resolve(root, outRel);
18468
+ const outPath = resolve2(root, outRel);
17592
18469
  if (opts.out !== void 0 && existsSync3(outPath) && !opts.force) {
17593
18470
  process.stderr.write(
17594
18471
  `Refusing to overwrite ${outRel} \u2014 pass --force to replace it (or omit --out to write .env.kryd).
@@ -17863,7 +18740,7 @@ Run \`kryd env list\` to see what is set. (If you meant the runtime variable of
17863
18740
  reportError(err);
17864
18741
  }
17865
18742
  }
17866
- var CLI_VERSION = true ? "0.6.0" : "0.0.0-dev";
18743
+ var CLI_VERSION = true ? "0.8.0" : "0.0.0-dev";
17867
18744
  var program = new Command();
17868
18745
  program.name("kryd").description("Kryd CLI").version(CLI_VERSION);
17869
18746
  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(
@@ -17874,7 +18751,7 @@ program.command("whoami").description("Show the current account").option("--api-
17874
18751
  program.command("logout").description("Clear the stored token").action(() => runLogout());
17875
18752
  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(
17876
18753
  "--framework <framework>",
17877
- "react-router | nextjs | vite-spa | node (auto-detected if omitted)"
18754
+ "react-router | nextjs | vite-spa | node (auto-detected if omitted; `workflow` comes from a committed kryd.json)"
17878
18755
  ).option(
17879
18756
  "--tenant <slug>",
17880
18757
  // KRYD-265 / KRYD-188: this used to say "claimed once on first init", which was false — signup
@@ -17959,6 +18836,16 @@ var ai = program.command("ai").description("Manage the app's EU AI gateway");
17959
18836
  ai.command("add [project]").description("Give the project an authenticated EU AI endpoint (injects on next deploy)").option("--api-url <url>", "control-plane API base URL").action((project2, opts) => runAiAdd({ ...opts, project: project2 }));
17960
18837
  ai.command("remove [project]").description("Stop injecting the project's AI endpoint (asks for confirmation)").option("--yes", "skip the confirmation prompt").option("--api-url <url>", "control-plane API base URL").action((project2, opts) => runAiRemove({ ...opts, project: project2 }));
17961
18838
  ai.command("status [project]").description("Show whether the project has an AI endpoint, and what it is").option("--api-url <url>", "control-plane API base URL").action((project2, opts) => runAiStatus({ ...opts, project: project2 }));
18839
+ var workflow = program.command("workflow").description("Manage the project's durable workflows (its own workflow tenant)");
18840
+ 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 }));
18841
+ 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 }));
18842
+ 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 }));
18843
+ workflow.command("init").description(
18844
+ "Scaffold a workflow worker here (with the health listener Kryd's deploy gate needs) and link it"
18845
+ ).requiredOption(
18846
+ "--language <language>",
18847
+ `worker language: ${SCAFFOLD_LANGUAGES.join(" | ")}`
18848
+ ).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));
17962
18849
  function invokedDirectly() {
17963
18850
  const entry = process.argv[1];
17964
18851
  if (!entry) return false;
@@ -18004,5 +18891,9 @@ export {
18004
18891
  runStorageRemove,
18005
18892
  runStorageStatus,
18006
18893
  runWhoami,
18894
+ runWorkflowAdd,
18895
+ runWorkflowInit,
18896
+ runWorkflowRemove,
18897
+ runWorkflowStatus,
18007
18898
  splitKeyValue
18008
18899
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kryd/cli",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Kryd CLI — push a React / Vite / Next.js app to the European cloud for your AI: git push → live with SSL, one-click managed Postgres & object storage, and an EU-hosted AI gateway already wired in. Your code and your model calls stay in the EU.",
5
5
  "keywords": [
6
6
  "kryd",
@@ -49,9 +49,9 @@
49
49
  "tsx": "^4.19.2",
50
50
  "typescript": "^5.6.3",
51
51
  "vitest": "^2.1.8",
52
- "@kryd/config-ts": "0.0.0",
52
+ "@kryd/shared-types": "0.0.0",
53
53
  "@kryd/config-eslint": "0.0.0",
54
- "@kryd/shared-types": "0.0.0"
54
+ "@kryd/config-ts": "0.0.0"
55
55
  },
56
56
  "scripts": {
57
57
  "build": "esbuild src/index.ts --bundle --platform=node --format=esm --external:commander --define:__KRYD_VERSION__=\\\"$npm_package_version\\\" --outfile=dist/index.js",