@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.
- package/README.md +2 -0
- package/dist/index.js +916 -25
- 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 {
|
|
14898
|
-
|
|
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((
|
|
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
|
-
|
|
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((
|
|
15778
|
+
return new Promise((resolve3) => {
|
|
15716
15779
|
const onAbort = () => {
|
|
15717
15780
|
clearTimeout(timer);
|
|
15718
|
-
|
|
15781
|
+
resolve3();
|
|
15719
15782
|
};
|
|
15720
15783
|
const timer = setTimeout(() => {
|
|
15721
15784
|
signal?.removeEventListener("abort", onAbort);
|
|
15722
|
-
|
|
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((
|
|
16223
|
-
rl.once("SIGINT", () =>
|
|
16224
|
-
rl.once("close", () =>
|
|
16225
|
-
rl.question("", (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((
|
|
16238
|
-
rl.once("SIGINT", () =>
|
|
16239
|
-
rl.once("close", () =>
|
|
16240
|
-
rl.question(question, (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((
|
|
16250
|
-
rl.once("SIGINT", () =>
|
|
16251
|
-
rl.once("close", () =>
|
|
16252
|
-
rl.question(`${question} [y/N] `, (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
|
|
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
|
|
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 =
|
|
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.
|
|
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.
|
|
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/
|
|
52
|
+
"@kryd/shared-types": "0.0.0",
|
|
53
53
|
"@kryd/config-eslint": "0.0.0",
|
|
54
|
-
"@kryd/
|
|
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",
|