@kryd/cli 0.7.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 +1 -0
  2. package/dist/index.js +732 -25
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -39,6 +39,7 @@ Once you've run `kryd init` in a directory, the commands below work **arg-less**
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
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. |
42
43
 
43
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).
44
45
 
package/dist/index.js CHANGED
@@ -14895,8 +14895,14 @@ function isTerminalResourceStatus(status) {
14895
14895
  }
14896
14896
 
14897
14897
  // src/index.ts
14898
- import { existsSync as existsSync3, realpathSync } from "node:fs";
14899
- 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";
14900
14906
  import { pathToFileURL } from "node:url";
14901
14907
 
14902
14908
  // src/config.ts
@@ -15018,6 +15024,11 @@ function ensureIgnored(cwd, entry, aliases = []) {
15018
15024
  function ensureKrydIgnored(cwd) {
15019
15025
  ensureIgnored(cwd, `${PROJECT_LINK_DIR}/`, [PROJECT_LINK_DIR]);
15020
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
+ }
15021
15032
  function writeSecretFile(path, contents) {
15022
15033
  mkdirSync(dirname(path), { recursive: true });
15023
15034
  writeFileSync(path, contents, { mode: 384 });
@@ -15058,7 +15069,7 @@ function browserLogin(dashboardUrl, opts = {}) {
15058
15069
  const state = randomUUID();
15059
15070
  const timeoutMs = opts.timeoutMs ?? 12e4;
15060
15071
  const shouldOpen = opts.open ?? true;
15061
- return new Promise((resolve2, reject) => {
15072
+ return new Promise((resolve3, reject) => {
15062
15073
  let settled = false;
15063
15074
  const settle = (fn) => {
15064
15075
  if (settled) return;
@@ -15090,7 +15101,7 @@ function browserLogin(dashboardUrl, opts = {}) {
15090
15101
  res.end(SUCCESS_HTML, () => {
15091
15102
  settle(() => {
15092
15103
  shutdown();
15093
- resolve2({ token, ...apiUrl ? { apiUrl } : {} });
15104
+ resolve3({ token, ...apiUrl ? { apiUrl } : {} });
15094
15105
  });
15095
15106
  });
15096
15107
  });
@@ -15764,14 +15775,14 @@ async function* readSseFrames(body) {
15764
15775
  }
15765
15776
  function abortableSleep(ms, signal) {
15766
15777
  if (signal?.aborted) return Promise.resolve();
15767
- return new Promise((resolve2) => {
15778
+ return new Promise((resolve3) => {
15768
15779
  const onAbort = () => {
15769
15780
  clearTimeout(timer);
15770
- resolve2();
15781
+ resolve3();
15771
15782
  };
15772
15783
  const timer = setTimeout(() => {
15773
15784
  signal?.removeEventListener("abort", onAbort);
15774
- resolve2();
15785
+ resolve3();
15775
15786
  }, ms);
15776
15787
  signal?.addEventListener("abort", onAbort, { once: true });
15777
15788
  });
@@ -15873,11 +15884,14 @@ var FRAMEWORKS = [
15873
15884
  "react-router",
15874
15885
  "nextjs",
15875
15886
  "vite-spa",
15876
- "node"
15887
+ "node",
15888
+ "workflow"
15877
15889
  ];
15878
15890
  function isFramework(value) {
15879
15891
  return FRAMEWORKS.includes(value);
15880
15892
  }
15893
+ var DECLARATION_FILE = "kryd.json";
15894
+ var DECLARATION_KIND_WORKFLOW = "workflow";
15881
15895
  var VITE_META_FRAMEWORKS = [
15882
15896
  "@sveltejs/kit",
15883
15897
  "astro",
@@ -15919,7 +15933,22 @@ function readPackageJson(cwd) {
15919
15933
  return null;
15920
15934
  }
15921
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
+ }
15922
15950
  function frameworkFrom(pkg, cwd) {
15951
+ if (declaredKind(cwd) === DECLARATION_KIND_WORKFLOW) return "workflow";
15923
15952
  const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
15924
15953
  const has = (name) => name in deps;
15925
15954
  if (has("next")) return "nextjs";
@@ -15941,6 +15970,528 @@ function inspectProject(cwd) {
15941
15970
  return { framework: frameworkFrom(pkg, cwd), name: nameFrom(pkg, cwd) };
15942
15971
  }
15943
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
+
15944
16495
  // src/progress.ts
15945
16496
  var STEPS = DEPLOY_STATES.filter(
15946
16497
  (s) => !TERMINAL_DEPLOY_STATES.includes(s)
@@ -16271,10 +16822,10 @@ async function promptHidden(question, io) {
16271
16822
  terminal: true
16272
16823
  });
16273
16824
  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));
16825
+ return await new Promise((resolve3) => {
16826
+ rl.once("SIGINT", () => resolve3(null));
16827
+ rl.once("close", () => resolve3(null));
16828
+ rl.question("", (answer) => resolve3(answer));
16278
16829
  });
16279
16830
  } finally {
16280
16831
  rl.close();
@@ -16286,10 +16837,10 @@ async function promptHidden(question, io) {
16286
16837
  async function promptLine(question, io) {
16287
16838
  const rl = createInterface({ input: io.input, output: io.output });
16288
16839
  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));
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));
16293
16844
  });
16294
16845
  } finally {
16295
16846
  rl.close();
@@ -16298,10 +16849,10 @@ async function promptLine(question, io) {
16298
16849
  async function promptConfirm(question, io) {
16299
16850
  const rl = createInterface({ input: io.input, output: io.output });
16300
16851
  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));
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));
16305
16856
  });
16306
16857
  return answer !== null && /^y(es)?$/i.test(answer.trim());
16307
16858
  } finally {
@@ -16311,6 +16862,7 @@ async function promptConfirm(question, io) {
16311
16862
 
16312
16863
  // src/git.ts
16313
16864
  import { execFileSync, spawnSync } from "node:child_process";
16865
+ import { resolve } from "node:path";
16314
16866
  function authenticatedRemoteUrl(cloneUrl, username, token) {
16315
16867
  const prefix = "https://";
16316
16868
  if (!cloneUrl.startsWith(prefix)) return cloneUrl;
@@ -16375,6 +16927,21 @@ function gitAvailable(cwd) {
16375
16927
  if (res.status !== "ok" || res.value !== "true") return { status: "not-a-repo" };
16376
16928
  return { status: "ok" };
16377
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
+ }
16378
16945
  function currentBranch(cwd) {
16379
16946
  return probe(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
16380
16947
  }
@@ -16463,12 +17030,31 @@ async function runInit(opts) {
16463
17030
  process.exitCode = 1;
16464
17031
  return;
16465
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
+ }
16466
17052
  const detected = inspectProject(cwd);
16467
17053
  let framework;
16468
17054
  if (opts.framework) {
16469
17055
  if (!isFramework(opts.framework)) {
16470
17056
  process.stderr.write(
16471
- `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.
16472
17058
  `
16473
17059
  );
16474
17060
  process.exitCode = 1;
@@ -16478,7 +17064,7 @@ async function runInit(opts) {
16478
17064
  } else {
16479
17065
  if (!detected.framework) {
16480
17066
  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"
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"
16482
17068
  );
16483
17069
  process.exitCode = 1;
16484
17070
  return;
@@ -17554,6 +18140,120 @@ async function runWorkflowStatus(opts) {
17554
18140
  reportError(err);
17555
18141
  }
17556
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
+ }
17557
18257
  async function runStorageRemove(opts) {
17558
18258
  const apiUrl = resolveApiUrl(opts.apiUrl);
17559
18259
  const token = loadConfig().token;
@@ -17765,7 +18465,7 @@ async function runEnvPull(opts) {
17765
18465
  const cwd = opts.cwd ?? process.cwd();
17766
18466
  const root = findProjectLinkDir(cwd) ?? cwd;
17767
18467
  const outRel = opts.out ?? ".env.kryd";
17768
- const outPath = resolve(root, outRel);
18468
+ const outPath = resolve2(root, outRel);
17769
18469
  if (opts.out !== void 0 && existsSync3(outPath) && !opts.force) {
17770
18470
  process.stderr.write(
17771
18471
  `Refusing to overwrite ${outRel} \u2014 pass --force to replace it (or omit --out to write .env.kryd).
@@ -18040,7 +18740,7 @@ Run \`kryd env list\` to see what is set. (If you meant the runtime variable of
18040
18740
  reportError(err);
18041
18741
  }
18042
18742
  }
18043
- var CLI_VERSION = true ? "0.7.0" : "0.0.0-dev";
18743
+ var CLI_VERSION = true ? "0.8.0" : "0.0.0-dev";
18044
18744
  var program = new Command();
18045
18745
  program.name("kryd").description("Kryd CLI").version(CLI_VERSION);
18046
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(
@@ -18051,7 +18751,7 @@ program.command("whoami").description("Show the current account").option("--api-
18051
18751
  program.command("logout").description("Clear the stored token").action(() => runLogout());
18052
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(
18053
18753
  "--framework <framework>",
18054
- "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)"
18055
18755
  ).option(
18056
18756
  "--tenant <slug>",
18057
18757
  // KRYD-265 / KRYD-188: this used to say "claimed once on first init", which was false — signup
@@ -18140,6 +18840,12 @@ var workflow = program.command("workflow").description("Manage the project's dur
18140
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 }));
18141
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 }));
18142
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));
18143
18849
  function invokedDirectly() {
18144
18850
  const entry = process.argv[1];
18145
18851
  if (!entry) return false;
@@ -18186,6 +18892,7 @@ export {
18186
18892
  runStorageStatus,
18187
18893
  runWhoami,
18188
18894
  runWorkflowAdd,
18895
+ runWorkflowInit,
18189
18896
  runWorkflowRemove,
18190
18897
  runWorkflowStatus,
18191
18898
  splitKeyValue
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kryd/cli",
3
- "version": "0.7.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/shared-types": "0.0.0",
52
53
  "@kryd/config-eslint": "0.0.0",
53
- "@kryd/config-ts": "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",