@kryd/cli 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +2 -1
  2. package/dist/index.js +508 -103
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -20,6 +20,7 @@ kryd init # link this folder to a Kryd project (+ a `kryd` g
20
20
  kryd push # push the current branch → build → deploy → live, with live progress
21
21
  kryd logs # the full build log of the latest deploy (kryd push shows progress, not log lines)
22
22
  kryd logs <project> --runtime # tail the live app's stdout/stderr
23
+ kryd logs <project> --worker # tail the workflow worker's stdout/stderr
23
24
  ```
24
25
 
25
26
  Once you've run `kryd init` in a directory, the commands below work **arg-less** there — the folder is linked via `.kryd/project.json` (git-ignored, no secret).
@@ -32,7 +33,7 @@ Once you've run `kryd init` in a directory, the commands below work **arg-less**
32
33
  | `kryd init` | Link the current repo to a Kryd project + register the deploy webhook + add a `kryd` git remote. |
33
34
  | `kryd push [branch]` | Push to the `kryd` remote and follow the deploy it triggers (defaults to the current branch). Shows which step the deploy is on, how long it has taken and a progress bar; `--logs` streams the full build log instead. |
34
35
  | `kryd deploy [project]` | Re-deploy the production-branch HEAD already on the forge — no new commit. |
35
- | `kryd logs [target]` | Follow a deploy's build/deploy log **in full** — this is where the build output lives (`--runtime` tails the live container instead). |
36
+ | `kryd logs [target]` | Follow a deploy's build/deploy log **in full** — this is where the build output lives (`--runtime` tails the live container instead; `--worker` tails the project's workflow worker, also when it has a web app). |
36
37
  | `kryd rollback [project] [deployment]` | Roll back to a previous successful deploy — no rebuild. |
37
38
  | `kryd db add \| remove \| status [project]` | Attach, tear down or inspect managed Postgres (shared or bring-your-own). `remove` destroys the data and asks first. |
38
39
  | `kryd storage add \| remove \| status [project]` | Attach, tear down or inspect S3-compatible object storage. `remove` destroys every object and asks first. |
package/dist/index.js CHANGED
@@ -56,6 +56,7 @@ function parseDeployEvent(payload) {
56
56
  }
57
57
 
58
58
  // ../../packages/shared-types/dist/runtime-log.js
59
+ var RUNTIME_REFUSAL_CODES = ["no-runtime", "pending"];
59
60
  function parseRuntimeStreamFrame(payload) {
60
61
  let parsed;
61
62
  try {
@@ -67,7 +68,8 @@ function parseRuntimeStreamFrame(payload) {
67
68
  return null;
68
69
  const e = parsed;
69
70
  if (e.kind === "error" && typeof e.message === "string") {
70
- return { kind: "error", message: e.message };
71
+ const code = RUNTIME_REFUSAL_CODES.includes(e.code) ? e.code : void 0;
72
+ return code ? { kind: "error", message: e.message, code } : { kind: "error", message: e.message };
71
73
  }
72
74
  if (e.kind !== "runtime" || e.stream !== "stdout" && e.stream !== "stderr" || typeof e.line !== "string") {
73
75
  return null;
@@ -14855,6 +14857,10 @@ function validateBuildEnvVarValue(value) {
14855
14857
  var BUILD_ENV_PUBLIC_WARNING = "\u26A0 Build-time values are compiled into your app's public bundle \u2014 anyone who loads your site can read them. Never put a secret here.";
14856
14858
  var BUILD_ENV_REBUILD_NOTE = "This takes effect at your next BUILD, not your next deploy \u2014 the value is compiled into the image, so redeploying the existing image keeps the old value. Push a commit (`kryd push`) to rebuild.";
14857
14859
 
14860
+ // ../../packages/shared-types/dist/workflow-run.js
14861
+ var MAX_WEBHOOK_NAME = 32;
14862
+ var WEBHOOK_NAME_RE = new RegExp(`^[a-z0-9](?:[a-z0-9-]{0,${MAX_WEBHOOK_NAME - 2}}[a-z0-9])?$`);
14863
+
14858
14864
  // ../../packages/shared-types/dist/index.js
14859
14865
  var DEPLOY_STATES = [
14860
14866
  "queued",
@@ -14898,6 +14904,7 @@ function isTerminalResourceStatus(status) {
14898
14904
  import {
14899
14905
  existsSync as existsSync3,
14900
14906
  mkdirSync as mkdirSync2,
14907
+ readFileSync as readFileSync3,
14901
14908
  realpathSync,
14902
14909
  rmSync as rmSync2,
14903
14910
  writeFileSync as writeFileSync2
@@ -14956,8 +14963,13 @@ function clearToken() {
14956
14963
  delete config2.token;
14957
14964
  writeConfig(config2);
14958
14965
  }
14966
+ var lastApiUrl = null;
14959
14967
  function resolveApiUrl(flag) {
14960
- return flag ?? process.env.KRYD_API_URL ?? loadConfig().apiUrl ?? DEFAULT_API_URL;
14968
+ lastApiUrl = flag ?? process.env.KRYD_API_URL ?? loadConfig().apiUrl ?? DEFAULT_API_URL;
14969
+ return lastApiUrl;
14970
+ }
14971
+ function currentApiUrl() {
14972
+ return lastApiUrl ?? resolveApiUrl();
14961
14973
  }
14962
14974
  var DEFAULT_DASHBOARD_URL = "https://app.kryd.eu";
14963
14975
  function resolveDashboardUrl(flag) {
@@ -15046,9 +15058,33 @@ function clearProjectLinkIfMatches(projectId, cwd = process.cwd()) {
15046
15058
  return false;
15047
15059
  }
15048
15060
  }
15061
+ function resolvedLink() {
15062
+ return lastLinkResolution;
15063
+ }
15064
+ var lastLinkResolution = null;
15049
15065
  function resolveProjectId(explicit, cwd = process.cwd()) {
15066
+ lastLinkResolution = null;
15050
15067
  if (explicit) return explicit;
15051
- return loadProjectLink(cwd)?.projectId ?? null;
15068
+ const root = findProjectLinkDir(cwd);
15069
+ if (!root) return null;
15070
+ const link = loadProjectLink(cwd);
15071
+ if (!link) return null;
15072
+ lastLinkResolution = { root, link };
15073
+ return link.projectId;
15074
+ }
15075
+ function rememberLinkAccount(root, account) {
15076
+ if (!account.accountId) return false;
15077
+ const link = directoryIsLinked(root);
15078
+ if (!link) return false;
15079
+ const slug = account.accountSlug ?? link.accountSlug;
15080
+ if (link.accountId === account.accountId && link.accountSlug === slug) return false;
15081
+ saveProjectLink(root, {
15082
+ ...link,
15083
+ accountId: account.accountId,
15084
+ // `exactOptionalPropertyTypes`: an absent key and an explicit `undefined` are different types.
15085
+ ...slug ? { accountSlug: slug } : {}
15086
+ });
15087
+ return true;
15052
15088
  }
15053
15089
 
15054
15090
  // src/browser-login.ts
@@ -15136,7 +15172,15 @@ Waiting for approval\u2026
15136
15172
  // src/client.ts
15137
15173
  var ApiError = class extends Error {
15138
15174
  envelope;
15139
- /** The HTTP status of the failed response, when it came from one (used to detect a 404 = gone). */
15175
+ /**
15176
+ * The HTTP status of the failed response, when it came from one (used to detect a 404 = gone).
15177
+ *
15178
+ * ⚠️ Still optional because an `ApiError` is also thrown for a well-formed response the CLI
15179
+ * cannot use ("the API returned an unexpected …") and for a stream's own error frame — neither
15180
+ * has a status. Every throw that DOES hold a `Response` now passes it (KRYD-481): the 404
15181
+ * diagnosis keys on the status, and a third of them used to omit it, which made the same
15182
+ * failure explainable or unexplainable depending on which verb the customer typed.
15183
+ */
15140
15184
  status;
15141
15185
  constructor(message, envelope, status) {
15142
15186
  super(message);
@@ -15161,7 +15205,8 @@ async function login(apiUrl, creds) {
15161
15205
  if (!res.ok) {
15162
15206
  throw new ApiError(
15163
15207
  `Login failed (${res.status})`,
15164
- await parseEnvelope(res)
15208
+ await parseEnvelope(res),
15209
+ res.status
15165
15210
  );
15166
15211
  }
15167
15212
  const token = res.headers.get("set-auth-token");
@@ -15177,7 +15222,8 @@ async function whoami(apiUrl, token) {
15177
15222
  if (!res.ok) {
15178
15223
  throw new ApiError(
15179
15224
  `Request failed (${res.status})`,
15180
- await parseEnvelope(res)
15225
+ await parseEnvelope(res),
15226
+ res.status
15181
15227
  );
15182
15228
  }
15183
15229
  return await res.json();
@@ -15194,7 +15240,8 @@ async function linkProject(apiUrl, token, input) {
15194
15240
  if (!res.ok) {
15195
15241
  throw new ApiError(
15196
15242
  `Repo linking failed (${res.status})`,
15197
- await parseEnvelope(res)
15243
+ await parseEnvelope(res),
15244
+ res.status
15198
15245
  );
15199
15246
  }
15200
15247
  const parsed = await res.json().catch(() => void 0);
@@ -15222,7 +15269,8 @@ async function attachDatabase(apiUrl, token, input) {
15222
15269
  if (!res.ok) {
15223
15270
  throw new ApiError(
15224
15271
  `Database attach failed (${res.status})`,
15225
- await parseEnvelope(res)
15272
+ await parseEnvelope(res),
15273
+ res.status
15226
15274
  );
15227
15275
  }
15228
15276
  const parsed = await res.json().catch(() => void 0);
@@ -15247,7 +15295,8 @@ async function createStorage(apiUrl, token, input) {
15247
15295
  if (!res.ok) {
15248
15296
  throw new ApiError(
15249
15297
  `Storage create failed (${res.status})`,
15250
- await parseEnvelope(res)
15298
+ await parseEnvelope(res),
15299
+ res.status
15251
15300
  );
15252
15301
  }
15253
15302
  const parsed = await res.json().catch(() => void 0);
@@ -15449,7 +15498,8 @@ async function detachDatabase(apiUrl, token, projectId) {
15449
15498
  if (!res.ok) {
15450
15499
  throw new ApiError(
15451
15500
  `Database detach failed (${res.status})`,
15452
- await parseEnvelope(res)
15501
+ await parseEnvelope(res),
15502
+ res.status
15453
15503
  );
15454
15504
  }
15455
15505
  }
@@ -15459,7 +15509,7 @@ async function getProject(apiUrl, token, projectId) {
15459
15509
  });
15460
15510
  if (res.status === 404) return null;
15461
15511
  if (!res.ok) {
15462
- throw new ApiError(`Reading the project failed (${res.status})`, await parseEnvelope(res));
15512
+ throw new ApiError(`Reading the project failed (${res.status})`, await parseEnvelope(res), res.status);
15463
15513
  }
15464
15514
  return await res.json();
15465
15515
  }
@@ -15468,7 +15518,7 @@ async function listProjects(apiUrl, token) {
15468
15518
  headers: { authorization: `Bearer ${token}` }
15469
15519
  });
15470
15520
  if (!res.ok) {
15471
- throw new ApiError(`Listing projects failed (${res.status})`, await parseEnvelope(res));
15521
+ throw new ApiError(`Listing projects failed (${res.status})`, await parseEnvelope(res), res.status);
15472
15522
  }
15473
15523
  return (await res.json()).items;
15474
15524
  }
@@ -15478,7 +15528,7 @@ async function deleteProject(apiUrl, token, projectId) {
15478
15528
  headers: { authorization: `Bearer ${token}` }
15479
15529
  });
15480
15530
  if (!res.ok) {
15481
- throw new ApiError(`Project delete failed (${res.status})`, await parseEnvelope(res));
15531
+ throw new ApiError(`Project delete failed (${res.status})`, await parseEnvelope(res), res.status);
15482
15532
  }
15483
15533
  return await res.json();
15484
15534
  }
@@ -15513,7 +15563,8 @@ async function detachStorage(apiUrl, token, projectId) {
15513
15563
  if (!res.ok) {
15514
15564
  throw new ApiError(
15515
15565
  `Storage detach failed (${res.status})`,
15516
- await parseEnvelope(res)
15566
+ await parseEnvelope(res),
15567
+ res.status
15517
15568
  );
15518
15569
  }
15519
15570
  }
@@ -15679,7 +15730,7 @@ async function triggerDeploy(apiUrl, token, projectId) {
15679
15730
  body: JSON.stringify({ projectId })
15680
15731
  });
15681
15732
  if (!res.ok) {
15682
- throw new ApiError(`Deploy trigger failed (${res.status})`, await parseEnvelope(res));
15733
+ throw new ApiError(`Deploy trigger failed (${res.status})`, await parseEnvelope(res), res.status);
15683
15734
  }
15684
15735
  return (await res.json()).deploymentId;
15685
15736
  }
@@ -15696,7 +15747,7 @@ async function rollbackDeploy(apiUrl, token, projectId, deploymentId) {
15696
15747
  })
15697
15748
  });
15698
15749
  if (!res.ok) {
15699
- throw new ApiError(`Rollback failed (${res.status})`, await parseEnvelope(res));
15750
+ throw new ApiError(`Rollback failed (${res.status})`, await parseEnvelope(res), res.status);
15700
15751
  }
15701
15752
  return await res.json();
15702
15753
  }
@@ -15710,7 +15761,7 @@ async function redeployProject(apiUrl, token, projectId) {
15710
15761
  body: JSON.stringify({ projectId })
15711
15762
  });
15712
15763
  if (!res.ok) {
15713
- throw new ApiError(`Redeploy failed (${res.status})`, await parseEnvelope(res));
15764
+ throw new ApiError(`Redeploy failed (${res.status})`, await parseEnvelope(res), res.status);
15714
15765
  }
15715
15766
  return await res.json();
15716
15767
  }
@@ -15719,7 +15770,7 @@ async function listDeployments(apiUrl, token, projectId) {
15719
15770
  if (projectId) url2.searchParams.set("projectId", projectId);
15720
15771
  const res = await fetch(url2, { headers: { authorization: `Bearer ${token}` } });
15721
15772
  if (!res.ok) {
15722
- throw new ApiError(`Listing deploys failed (${res.status})`, await parseEnvelope(res));
15773
+ throw new ApiError(`Listing deploys failed (${res.status})`, await parseEnvelope(res), res.status);
15723
15774
  }
15724
15775
  return (await res.json()).items;
15725
15776
  }
@@ -15798,7 +15849,7 @@ async function streamDeploy(apiUrl, token, deploymentId, handlers, opts = {}) {
15798
15849
  headers: { authorization: `Bearer ${token}`, accept: "text/event-stream" }
15799
15850
  });
15800
15851
  if (!res.ok) {
15801
- throw new ApiError(`Log stream failed (${res.status})`, await parseEnvelope(res));
15852
+ throw new ApiError(`Log stream failed (${res.status})`, await parseEnvelope(res), res.status);
15802
15853
  }
15803
15854
  if (!res.body) throw new Error("The log stream returned no body.");
15804
15855
  for await (const data of readSseFrames(res.body)) {
@@ -15826,13 +15877,13 @@ async function streamDeploy(apiUrl, token, deploymentId, handlers, opts = {}) {
15826
15877
  return terminal;
15827
15878
  }
15828
15879
  async function streamRuntime(apiUrl, token, id, handlers, opts = {}) {
15829
- const basePath = opts.resource === "deployment" ? "deployments" : "projects";
15880
+ const path = opts.resource === "deployment" ? `deployments/${id}/runtime-logs` : opts.resource === "worker" ? `projects/${id}/workflow-logs` : `projects/${id}/runtime-logs`;
15830
15881
  const doFetch = opts.fetchImpl ?? fetch;
15831
15882
  const reconnectMs = opts.reconnectMs ?? 1e3;
15832
15883
  let afterNs;
15833
15884
  while (!opts.signal?.aborted) {
15834
15885
  try {
15835
- const url2 = new URL(`${apiUrl}/${basePath}/${id}/runtime-logs`);
15886
+ const url2 = new URL(`${apiUrl}/${path}`);
15836
15887
  if (afterNs) url2.searchParams.set("after", afterNs);
15837
15888
  else if (opts.since) url2.searchParams.set("since", opts.since);
15838
15889
  const res = await doFetch(url2, {
@@ -15842,7 +15893,8 @@ async function streamRuntime(apiUrl, token, id, handlers, opts = {}) {
15842
15893
  if (!res.ok) {
15843
15894
  throw new ApiError(
15844
15895
  `Runtime log stream failed (${res.status})`,
15845
- await parseEnvelope(res)
15896
+ await parseEnvelope(res),
15897
+ res.status
15846
15898
  );
15847
15899
  }
15848
15900
  if (!res.body) throw new Error("The runtime log stream returned no body.");
@@ -15871,11 +15923,59 @@ async function fetchDeployLog(apiUrl, token, deploymentId, opts = {}) {
15871
15923
  if (!res.ok) {
15872
15924
  throw new ApiError(
15873
15925
  `Fetching the stored log failed (${res.status})`,
15874
- await parseEnvelope(res)
15926
+ await parseEnvelope(res),
15927
+ res.status
15875
15928
  );
15876
15929
  }
15877
15930
  return (await res.json()).log;
15878
15931
  }
15932
+ async function fetchPushCredential(apiUrl, token, projectId) {
15933
+ const res = await fetch(
15934
+ `${apiUrl}/repos/${encodeURIComponent(projectId)}/push-credential`,
15935
+ { headers: { authorization: `Bearer ${token}` } }
15936
+ );
15937
+ if (!res.ok) {
15938
+ throw new ApiError(
15939
+ `Could not fetch the push credential (${res.status})`,
15940
+ await parseEnvelope(res),
15941
+ res.status
15942
+ );
15943
+ }
15944
+ const parsed = await res.json().catch(() => void 0);
15945
+ if (!parsed || typeof parsed.username !== "string" || typeof parsed.password !== "string" || parsed.password.length === 0) {
15946
+ throw new ApiError("The API returned an unexpected push-credential response.");
15947
+ }
15948
+ return { username: parsed.username, password: parsed.password };
15949
+ }
15950
+
15951
+ // src/git-credential.ts
15952
+ async function runGitCredential(operation, opts = {}) {
15953
+ if (operation !== "get") return;
15954
+ const input = parseCredentialInput(opts.stdin ?? "");
15955
+ if (input.protocol && input.protocol !== "https") return;
15956
+ const cwd = opts.cwd ?? process.cwd();
15957
+ const token = loadConfig().token;
15958
+ if (!token) return;
15959
+ const projectId = resolveProjectId(void 0, cwd);
15960
+ if (!projectId) return;
15961
+ try {
15962
+ const cred = await fetchPushCredential(resolveApiUrl(opts.apiUrl), token, projectId);
15963
+ process.stdout.write(`username=${cred.username}
15964
+ password=${cred.password}
15965
+ `);
15966
+ } catch {
15967
+ }
15968
+ }
15969
+ function parseCredentialInput(raw) {
15970
+ const out = {};
15971
+ for (const line of raw.split("\n")) {
15972
+ if (line === "") continue;
15973
+ const eq = line.indexOf("=");
15974
+ if (eq <= 0) continue;
15975
+ out[line.slice(0, eq)] = line.slice(eq + 1);
15976
+ }
15977
+ return out;
15978
+ }
15879
15979
 
15880
15980
  // src/framework.ts
15881
15981
  import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
@@ -15891,7 +15991,13 @@ function isFramework(value) {
15891
15991
  return FRAMEWORKS.includes(value);
15892
15992
  }
15893
15993
  var DECLARATION_FILE = "kryd.json";
15894
- var DECLARATION_KIND_WORKFLOW = "workflow";
15994
+ var DEFAULT_WORKFLOWS_PATH = "workflows";
15995
+ function declarationJson(decl) {
15996
+ return {
15997
+ ...decl.web ? {} : { web: false },
15998
+ ...decl.workflowsPath !== null ? { workflows: { path: decl.workflowsPath } } : {}
15999
+ };
16000
+ }
15895
16001
  var VITE_META_FRAMEWORKS = [
15896
16002
  "@sveltejs/kit",
15897
16003
  "astro",
@@ -15933,22 +16039,26 @@ function readPackageJson(cwd) {
15933
16039
  return null;
15934
16040
  }
15935
16041
  }
15936
- function declaredKind(cwd) {
16042
+ function readDeclaration(cwd) {
16043
+ let parsed;
15937
16044
  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;
16045
+ parsed = JSON.parse(readFileSync2(join2(cwd, DECLARATION_FILE), "utf8"));
15946
16046
  } catch {
15947
16047
  return null;
15948
16048
  }
16049
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
16050
+ return null;
16051
+ }
16052
+ const decl = parsed;
16053
+ const workflows = decl.workflows;
16054
+ const workflowsPath = workflows && typeof workflows === "object" && typeof workflows.path === "string" ? workflows.path : null;
16055
+ return { web: decl.web !== false, workflowsPath };
15949
16056
  }
15950
16057
  function frameworkFrom(pkg, cwd) {
15951
- if (declaredKind(cwd) === DECLARATION_KIND_WORKFLOW) return "workflow";
16058
+ const declaration = readDeclaration(cwd);
16059
+ if (declaration && !declaration.web && declaration.workflowsPath !== null) {
16060
+ return "workflow";
16061
+ }
15952
16062
  const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
15953
16063
  const has = (name) => name in deps;
15954
16064
  if (has("next")) return "nextjs";
@@ -16043,6 +16153,10 @@ export const hatchet = HatchetClient.init();
16043
16153
  * One task, to prove the wiring end to end. Add your own beside it and list them in
16044
16154
  * \`src/worker.ts\` \u2014 everything about how a task runs (retries, timeouts, concurrency, crons,
16045
16155
  * DAGs) belongs to the engine's SDK, not to Kryd.
16156
+ *
16157
+ * A task can be cut off by a deploy or a restart: measured, a TypeScript worker stops within seconds
16158
+ * and the engine runs the task again from the start on another worker ~30 s later, even with
16159
+ * \`retries: 0\` \u2014 so write every task so that running it again is safe.
16046
16160
  */
16047
16161
  export const greet = hatchet.task({
16048
16162
  name: "greet",
@@ -16091,7 +16205,15 @@ async function main(): Promise<void> {
16091
16205
  const { hatchet } = await import("./hatchet");
16092
16206
  const { greet } = await import("./tasks");
16093
16207
 
16094
- const worker = await hatchet.worker(WORKER_NAME, { workflows: [greet] });
16208
+ // Kryd sets KRYD_DEPLOYMENT_ID on a deployed worker, and the label is how its deploy gate tells
16209
+ // THIS deploy's worker from the previous one, which keeps running while it drains. Keep the line:
16210
+ // the gate requires it, and a worker without it never counts as this deploy's, so the deploy
16211
+ // never goes live. Locally the variable is absent, so no label.
16212
+ const deployId = process.env.KRYD_DEPLOYMENT_ID;
16213
+ const worker = await hatchet.worker(WORKER_NAME, {
16214
+ workflows: [greet],
16215
+ labels: deployId ? { "kryd-deploy": deployId } : undefined,
16216
+ });
16095
16217
 
16096
16218
  // start() resolves when the worker stops, so it is not awaited here; waitUntilReady() flips the
16097
16219
  // health flag once the engine has the worker. A failed registration rejects \`running\` and the
@@ -16157,6 +16279,11 @@ from hatchet_sdk import Context, EmptyModel
16157
16279
  # One task, to prove the wiring end to end. Add your own beside it and list them in main.py \u2014
16158
16280
  # everything about how a task runs (retries, timeouts, concurrency, crons, DAGs) belongs to the
16159
16281
  # engine's SDK, not to Kryd.
16282
+ #
16283
+ # A task can be cut off by a deploy or a restart: a Python worker finishes a running task before it
16284
+ # stops (measured with 33 s left), but the stop has a 90 s grace period, and a task still running
16285
+ # after it is killed and run again from the start on another worker, even with retries=0 (measured)
16286
+ # \u2014 so write every task so that running it again is safe.
16160
16287
  @hatchet.task(name="greet")
16161
16288
  def greet(input: EmptyModel, ctx: Context) -> dict[str, str]:
16162
16289
  return {"greeting": "Hello, world"}
@@ -16333,6 +16460,11 @@ type GreetOutput struct {
16333
16460
  // Greet is one task, to prove the wiring end to end. Add your own beside it and register them in
16334
16461
  // main.go \u2014 everything about how a task runs (retries, timeouts, concurrency, crons, DAGs) belongs
16335
16462
  // to the engine's SDK, not to Kryd.
16463
+ //
16464
+ // A task can be cut off by a deploy or a restart: measured, a Go worker exits within a second of the
16465
+ // stop signal without waiting for running tasks, and the engine runs the task again from the start
16466
+ // on another worker ~30 s later, even with retries set to 0 \u2014 so write every task so that running it
16467
+ // again is safe.
16336
16468
  func Greet(c *hatchet.Client) *hatchet.StandaloneTask {
16337
16469
  return c.NewStandaloneTask("greet", func(ctx hatchet.Context, input GreetInput) (GreetOutput, error) {
16338
16470
  name := input.Name
@@ -16415,7 +16547,15 @@ func main() {
16415
16547
  log.Fatalf("could not create the Hatchet client: %v", err)
16416
16548
  }
16417
16549
 
16418
- worker, err := client.NewWorker(workerName, hatchet.WithWorkflows(Greet(client)))
16550
+ // Kryd sets KRYD_DEPLOYMENT_ID on a deployed worker, and the label is how its deploy gate tells
16551
+ // THIS deploy's worker from the previous one, which keeps running while it drains. Keep it:
16552
+ // the gate requires it, and a worker without it never counts as this deploy's, so the deploy
16553
+ // never goes live. Locally the variable is absent, so no label.
16554
+ opts := []hatchet.WorkerOption{hatchet.WithWorkflows(Greet(client))}
16555
+ if deployID := os.Getenv("KRYD_DEPLOYMENT_ID"); deployID != "" {
16556
+ opts = append(opts, hatchet.WithLabels(map[string]any{"kryd-deploy": deployID}))
16557
+ }
16558
+ worker, err := client.NewWorker(workerName, opts...)
16419
16559
  if err != nil {
16420
16560
  log.Fatalf("could not create the worker: %v", err)
16421
16561
  }
@@ -16474,15 +16614,19 @@ var TEMPLATES = {
16474
16614
  python: PYTHON_TEMPLATE,
16475
16615
  go: GO_TEMPLATE
16476
16616
  };
16477
- function declarationFile() {
16617
+ function declarationFile(decl) {
16478
16618
  return {
16479
16619
  path: DECLARATION_FILE,
16480
- contents: `${JSON.stringify({ kind: DECLARATION_KIND_WORKFLOW }, null, 2)}
16620
+ contents: `${JSON.stringify(declarationJson(decl), null, 2)}
16481
16621
  `
16482
16622
  };
16483
16623
  }
16624
+ var WORKER_ONLY = { web: false, workflowsPath: "." };
16625
+ function workerFiles(language, workerName) {
16626
+ return TEMPLATES[language].files(workerName);
16627
+ }
16484
16628
  function scaffoldFiles(language, workerName) {
16485
- return [...TEMPLATES[language].files(workerName), declarationFile()];
16629
+ return [...workerFiles(language, workerName), declarationFile(WORKER_ONLY)];
16486
16630
  }
16487
16631
  function firstCommand(language) {
16488
16632
  return TEMPLATES[language].firstCommand;
@@ -16863,12 +17007,6 @@ async function promptConfirm(question, io) {
16863
17007
  // src/git.ts
16864
17008
  import { execFileSync, spawnSync } from "node:child_process";
16865
17009
  import { resolve } from "node:path";
16866
- function authenticatedRemoteUrl(cloneUrl, username, token) {
16867
- const prefix = "https://";
16868
- if (!cloneUrl.startsWith(prefix)) return cloneUrl;
16869
- const creds = `${encodeURIComponent(username)}:${encodeURIComponent(token)}@`;
16870
- return prefix + creds + cloneUrl.slice(prefix.length);
16871
- }
16872
17010
  function configureGitRemote(cwd, remote, url2) {
16873
17011
  const run = (args) => execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
16874
17012
  try {
@@ -16961,9 +17099,32 @@ function pushBranch(cwd, remote, branch, extraArgs) {
16961
17099
  if (res.status === 0) return { status: "ok" };
16962
17100
  return { status: "failed", code: res.status ?? 1 };
16963
17101
  }
17102
+ function configureCredentialHelper(cwd, cloneUrl) {
17103
+ const origin = originOf(cloneUrl);
17104
+ if (!origin) return "unavailable";
17105
+ const run = (args) => {
17106
+ try {
17107
+ execFileSync("git", args, { cwd, stdio: ["ignore", "ignore", "ignore"] });
17108
+ return true;
17109
+ } catch {
17110
+ return false;
17111
+ }
17112
+ };
17113
+ const ok = run(["config", `credential.${origin}.helper`, ""]) && run(["config", "--add", `credential.${origin}.helper`, "!kryd git-credential"]) && run(["config", `credential.${origin}.useHttpPath`, "true"]);
17114
+ return ok ? "set" : "unavailable";
17115
+ }
17116
+ function originOf(url2) {
17117
+ try {
17118
+ const parsed = new URL(url2);
17119
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null;
17120
+ return `${parsed.protocol}//${parsed.host}`;
17121
+ } catch {
17122
+ return null;
17123
+ }
17124
+ }
16964
17125
 
16965
17126
  // src/index.ts
16966
- function reportError(err) {
17127
+ async function reportError(err) {
16967
17128
  if (err instanceof ApiError && err.envelope) {
16968
17129
  process.stderr.write(
16969
17130
  `${err.envelope.error.code}: ${err.envelope.error.message}
@@ -16974,6 +17135,63 @@ function reportError(err) {
16974
17135
  `);
16975
17136
  }
16976
17137
  process.exitCode = 1;
17138
+ if (err instanceof ApiError) {
17139
+ process.stderr.write(await explainMissingProject(err));
17140
+ }
17141
+ }
17142
+ async function projectIsVisible(token, projectId) {
17143
+ try {
17144
+ return await getProject(currentApiUrl(), token, projectId) !== null;
17145
+ } catch {
17146
+ return null;
17147
+ }
17148
+ }
17149
+ function accountLabel(slug, id) {
17150
+ return slug ?? id;
17151
+ }
17152
+ async function explainMissingProject(err) {
17153
+ if (err.status !== 404) return "";
17154
+ const resolution = resolvedLink();
17155
+ if (!resolution) return "";
17156
+ const token = loadConfig().token;
17157
+ if (!token) return "";
17158
+ if (await projectIsVisible(token, resolution.link.projectId) !== false) return "";
17159
+ let session;
17160
+ try {
17161
+ session = await whoami(currentApiUrl(), token);
17162
+ } catch {
17163
+ return "";
17164
+ }
17165
+ if (!session.accountId) return "";
17166
+ const signedInAs = accountLabel(session.accountSlug, session.accountId);
17167
+ const linkedAccount = resolution.link.accountId;
17168
+ if (!linkedAccount) {
17169
+ return `This folder is linked to project ${resolution.link.projectId}, and you are signed in as ${signedInAs}. That project is not on this account \u2014 if it belongs to another one, switch with \`kryd login\` and run this again.
17170
+ `;
17171
+ }
17172
+ if (linkedAccount === session.accountId) {
17173
+ return `This folder is linked to project ${resolution.link.projectId} on ${signedInAs}, which is the account you are signed in as \u2014 so that project no longer exists.
17174
+ `;
17175
+ }
17176
+ return `This folder is linked to a project of account ${accountLabel(resolution.link.accountSlug, linkedAccount)}, but you are signed in as ${signedInAs}. Switch with \`kryd login\` and run this again.
17177
+ `;
17178
+ }
17179
+ async function backfillLinkAccount() {
17180
+ if (process.exitCode) return;
17181
+ const resolution = resolvedLink();
17182
+ if (!resolution) return;
17183
+ if (resolution.link.accountId && resolution.link.accountSlug) return;
17184
+ const token = loadConfig().token;
17185
+ if (!token) return;
17186
+ if (await projectIsVisible(token, resolution.link.projectId) !== true) return;
17187
+ try {
17188
+ const session = await whoami(currentApiUrl(), token);
17189
+ rememberLinkAccount(resolution.root, {
17190
+ accountId: session.accountId,
17191
+ accountSlug: session.accountSlug
17192
+ });
17193
+ } catch {
17194
+ }
16977
17195
  }
16978
17196
  function reportNotLinked(example) {
16979
17197
  process.stderr.write(
@@ -17014,7 +17232,7 @@ async function runWhoami(opts = {}) {
17014
17232
  `
17015
17233
  );
17016
17234
  } catch (err) {
17017
- reportError(err);
17235
+ await reportError(err);
17018
17236
  }
17019
17237
  }
17020
17238
  function runLogout() {
@@ -17073,7 +17291,7 @@ To deploy it: \`kryd push\`. To start over: \`kryd project rm\` first, or delete
17073
17291
  }
17074
17292
  const name = opts.name ?? detected.name;
17075
17293
  try {
17076
- const { project: project2, repo, pushToken } = await linkProject(apiUrl, token, {
17294
+ const { project: project2, repo } = await linkProject(apiUrl, token, {
17077
17295
  name,
17078
17296
  framework,
17079
17297
  // First `kryd init` for the account claims the tenant slug (the vanity routing key in
@@ -17087,6 +17305,14 @@ To deploy it: \`kryd push\`. To start over: \`kryd project rm\` first, or delete
17087
17305
  let linkNote = "";
17088
17306
  try {
17089
17307
  saveProjectLink(cwd, { projectId: project2.id });
17308
+ try {
17309
+ const session = await whoami(apiUrl, token);
17310
+ rememberLinkAccount(cwd, {
17311
+ accountId: session.accountId,
17312
+ accountSlug: session.accountSlug
17313
+ });
17314
+ } catch {
17315
+ }
17090
17316
  linkNote = "Linked this folder \u2192 .kryd/project.json \u2014 `kryd deploy` / `kryd logs` now work here with no id.\n";
17091
17317
  try {
17092
17318
  ensureKrydIgnored(cwd);
@@ -17103,44 +17329,52 @@ To deploy it: \`kryd push\`. To start over: \`kryd project rm\` first, or delete
17103
17329
  );
17104
17330
  }
17105
17331
  const branch = repo.defaultBranch;
17106
- const slug = repo.fullName.split("/")[0] ?? "kryd";
17107
- const authedUrl = authenticatedRemoteUrl(repo.cloneUrl, slug, pushToken);
17108
- const remote = configureGitRemote(cwd, "kryd", authedUrl);
17332
+ const remote = configureGitRemote(cwd, "kryd", repo.cloneUrl);
17333
+ const helper = configureCredentialHelper(cwd, repo.cloneUrl);
17109
17334
  let nextStep;
17110
17335
  switch (remote.status) {
17111
17336
  case "added":
17112
17337
  case "updated":
17113
- nextStep = `${remote.status === "added" ? "Added" : "Updated"} git remote "${remote.remote}" (with your push credential).
17114
- ` + (remote.tracking === "set" ? `Tracking set: \`git push\` and \`kryd push\` both deploy this branch.
17338
+ nextStep = `${remote.status === "added" ? "Added" : "Updated"} git remote "${remote.remote}".
17339
+ ` + (helper === "set" ? "" : `\u26A0\uFE0F Could not register the credential helper, so git will ask for a password on push.
17340
+ Run \`kryd login\` and re-run \`kryd init\` here to fix it.
17341
+ `) + (remote.tracking === "set" ? `Tracking set: \`git push\` and \`kryd push\` both deploy this branch.
17115
17342
  ` : remote.tracking === "kept-existing" ? `This branch already tracks another remote, so it was left alone \u2014 deploy with \`kryd push\`.
17116
17343
  ` : "") + `Next: kryd push # push to deploy (same as \`git push ${remote.remote} ${branch}\`)
17117
17344
  `;
17118
17345
  break;
17119
17346
  case "not-a-repo":
17120
- nextStep = `No git repo here yet. To deploy (the URL carries your push token \u2014 keep it private):
17347
+ nextStep = `No git repo here yet. To deploy:
17121
17348
  git init && git add -A && git commit -m "init"
17122
- git remote add kryd ${authedUrl}
17349
+ git remote add kryd ${repo.cloneUrl}
17350
+ kryd init # again, to register the credential helper
17123
17351
  kryd push
17124
17352
  `;
17125
17353
  break;
17126
17354
  case "unavailable":
17127
17355
  nextStep = gitAvailable(cwd).status === "no-git" ? `git is not installed, so the deploy remote could not be configured.
17128
17356
  Install git (https://git-scm.com/downloads), then re-run \`kryd init\` here.
17129
- ` : `Add the remote, then push to deploy (the URL carries your push token \u2014 keep it private):
17130
- git remote add kryd ${authedUrl}
17357
+ ` : `Add the remote, then push to deploy:
17358
+ git remote add kryd ${repo.cloneUrl}
17131
17359
  kryd push
17132
17360
  `;
17133
17361
  break;
17134
17362
  }
17363
+ const declaration = readDeclaration(cwd);
17364
+ const workerOnly = declaration !== null ? !declaration.web && declaration.workflowsPath !== null : project2.framework === "workflow";
17365
+ const routing = workerOnly ? `No public URL: a workflow worker dials out to the engine and serves nothing.
17366
+ Scheduled and triggered work runs on your production deploy; preview branches build but do not start a worker.
17367
+ ` : `Production URL: https://${project2.subdomain}.${PRODUCTION_TLD}
17368
+ Preview branches deploy to https://<branch>-<hash>-${project2.subdomain}.${PREVIEW_TLD}
17369
+ ` + (declaration?.workflowsPath ? `Workflows in ${declaration.workflowsPath}/ run as a worker beside it on production deploys; previews run the web app only.
17370
+ ` : "");
17135
17371
  process.stdout.write(
17136
17372
  `Linked "${name}" (${project2.framework}) \u2192 ${repo.htmlUrl}
17137
- Production URL: https://${project2.subdomain}.${PRODUCTION_TLD}
17138
- Preview branches deploy to https://<branch>-<hash>-${project2.subdomain}.${PREVIEW_TLD}
17139
- ${linkNote}
17373
+ ` + routing + `${linkNote}
17140
17374
  ${nextStep}`
17141
17375
  );
17142
17376
  } catch (err) {
17143
- reportError(err);
17377
+ await reportError(err);
17144
17378
  }
17145
17379
  }
17146
17380
  function formatStatus(event) {
@@ -17256,7 +17490,7 @@ async function runLogs(opts) {
17256
17490
  }
17257
17491
  await followDeploy(apiUrl, token, deploymentId);
17258
17492
  } catch (err) {
17259
- reportError(err);
17493
+ await reportError(err);
17260
17494
  }
17261
17495
  }
17262
17496
  async function runRuntimeLogs(opts) {
@@ -17268,9 +17502,16 @@ async function runRuntimeLogs(opts) {
17268
17502
  return;
17269
17503
  }
17270
17504
  const isDeployment = opts.project?.startsWith("dpl_") ?? false;
17505
+ if (opts.worker && isDeployment) {
17506
+ process.stderr.write(
17507
+ "--worker takes a project, not a deployment id: use `kryd logs <project> --worker` to follow the project's worker.\n"
17508
+ );
17509
+ process.exitCode = 1;
17510
+ return;
17511
+ }
17271
17512
  const target = isDeployment ? opts.project : resolveProjectId(opts.project, opts.cwd);
17272
17513
  if (!target) {
17273
- reportNotLinked("kryd logs <projectId> --runtime");
17514
+ reportNotLinked(opts.worker ? "kryd logs <projectId> --worker" : "kryd logs <projectId> --runtime");
17274
17515
  return;
17275
17516
  }
17276
17517
  if (opts.since !== void 0 && !/^\d+(s|m|h|d)$/.test(opts.since.trim())) {
@@ -17299,11 +17540,11 @@ async function runRuntimeLogs(opts) {
17299
17540
  {
17300
17541
  ...opts.since ? { since: opts.since } : {},
17301
17542
  signal: controller.signal,
17302
- ...isDeployment ? { resource: "deployment" } : {}
17543
+ ...isDeployment ? { resource: "deployment" } : opts.worker ? { resource: "worker" } : {}
17303
17544
  }
17304
17545
  );
17305
17546
  } catch (err) {
17306
- reportError(err);
17547
+ await reportError(err);
17307
17548
  } finally {
17308
17549
  process.off("SIGINT", onSigint);
17309
17550
  }
@@ -17327,7 +17568,7 @@ async function runDeploy(opts) {
17327
17568
  `);
17328
17569
  await followDeploy(apiUrl, token, deploymentId, { render: renderFor(opts.logs) });
17329
17570
  } catch (err) {
17330
- reportError(err);
17571
+ await reportError(err);
17331
17572
  }
17332
17573
  }
17333
17574
  function renderFor(logs) {
@@ -17465,7 +17706,7 @@ async function runPush(opts) {
17465
17706
  estimateFrom: recent
17466
17707
  });
17467
17708
  } catch (err) {
17468
- reportError(err);
17709
+ await reportError(err);
17469
17710
  }
17470
17711
  }
17471
17712
  async function runRollback(opts) {
@@ -17495,7 +17736,7 @@ async function runRollback(opts) {
17495
17736
  );
17496
17737
  await followDeploy(apiUrl, token, deploymentId, { render: renderFor(opts.logs) });
17497
17738
  } catch (err) {
17498
- reportError(err);
17739
+ await reportError(err);
17499
17740
  }
17500
17741
  }
17501
17742
  async function runRedeploy(opts) {
@@ -17524,7 +17765,7 @@ ${note}
17524
17765
  );
17525
17766
  await followDeploy(apiUrl, token, deploymentId, { render: renderFor(opts.logs) });
17526
17767
  } catch (err) {
17527
- reportError(err);
17768
+ await reportError(err);
17528
17769
  }
17529
17770
  }
17530
17771
  async function runDbAdd(opts) {
@@ -17589,7 +17830,7 @@ The connection string will be injected as DATABASE_URL on your next deploy.
17589
17830
  retryCmd: `kryd db status ${project2}`
17590
17831
  });
17591
17832
  } catch (err) {
17592
- reportError(err);
17833
+ await reportError(err);
17593
17834
  }
17594
17835
  }
17595
17836
  function reportSettleResult(result, opts) {
@@ -17636,7 +17877,7 @@ Its S3 credentials will be injected on your next deploy.
17636
17877
  retryCmd: `kryd storage status ${project2}`
17637
17878
  });
17638
17879
  } catch (err) {
17639
- reportError(err);
17880
+ await reportError(err);
17640
17881
  }
17641
17882
  }
17642
17883
  function reportDetachResult(result, opts) {
@@ -17679,7 +17920,7 @@ async function resolveProjectIdByName(apiUrl, token, name) {
17679
17920
  try {
17680
17921
  projects = await listProjects(apiUrl, token);
17681
17922
  } catch (err) {
17682
- reportError(err);
17923
+ await reportError(err);
17683
17924
  return null;
17684
17925
  }
17685
17926
  const matches = projects.filter((p) => p.name === name);
@@ -17740,7 +17981,7 @@ async function runProjectRemove(opts) {
17740
17981
  }
17741
17982
  resources = project2.status === "delete_failed" ? ["whatever the previous attempt did not manage to remove"] : await describeProjectResources(apiUrl, token, projectId);
17742
17983
  } catch (err) {
17743
- reportError(err);
17984
+ await reportError(err);
17744
17985
  return;
17745
17986
  }
17746
17987
  if (!opts.yes) {
@@ -17777,7 +18018,7 @@ async function runProjectRemove(opts) {
17777
18018
  );
17778
18019
  await followProjectDeletion(apiUrl, token, projectId, project2.name, opts);
17779
18020
  } catch (err) {
17780
- reportError(err);
18021
+ await reportError(err);
17781
18022
  }
17782
18023
  }
17783
18024
  async function followProjectDeletion(apiUrl, token, projectId, name, opts) {
@@ -17853,7 +18094,7 @@ async function runDbRemove(opts) {
17853
18094
  retryCmd: `kryd db remove ${project2}`
17854
18095
  });
17855
18096
  } catch (err) {
17856
- reportError(err);
18097
+ await reportError(err);
17857
18098
  }
17858
18099
  }
17859
18100
  async function runAiAdd(opts) {
@@ -17878,7 +18119,7 @@ async function runAiAdd(opts) {
17878
18119
  `
17879
18120
  );
17880
18121
  } catch (err) {
17881
- reportError(err);
18122
+ await reportError(err);
17882
18123
  }
17883
18124
  }
17884
18125
  async function runAiRemove(opts) {
@@ -17898,7 +18139,7 @@ async function runAiRemove(opts) {
17898
18139
  try {
17899
18140
  current = await getAiStatus(apiUrl, token, project2);
17900
18141
  } catch (err) {
17901
- reportError(err);
18142
+ await reportError(err);
17902
18143
  return;
17903
18144
  }
17904
18145
  if (!current.enabled) {
@@ -17923,7 +18164,7 @@ AI_GATEWAY_URL and AI_GATEWAY_TOKEN stop being injected from your next deploy (k
17923
18164
  `
17924
18165
  );
17925
18166
  } catch (err) {
17926
- reportError(err);
18167
+ await reportError(err);
17927
18168
  }
17928
18169
  }
17929
18170
  async function reportResourceStatus(opts) {
@@ -17940,12 +18181,12 @@ Remove it before attaching another.
17940
18181
  process.stdout.write(`${opts.project}: ${opts.noun} ${result.status}.
17941
18182
  `);
17942
18183
  } catch (err) {
17943
- if (err instanceof ApiError && err.status === 404) {
18184
+ if (err instanceof ApiError && err.status === 404 && await projectIsVisible(opts.token, opts.project) === true) {
17944
18185
  process.stdout.write(`${opts.project}: no ${opts.noun} attached.
17945
18186
  `);
17946
18187
  return;
17947
18188
  }
17948
- reportError(err);
18189
+ await reportError(err);
17949
18190
  }
17950
18191
  }
17951
18192
  async function runDbStatus(opts) {
@@ -17964,7 +18205,8 @@ async function runDbStatus(opts) {
17964
18205
  await reportResourceStatus({
17965
18206
  read: () => getDatabaseStatus(apiUrl, token, project2),
17966
18207
  noun: "database",
17967
- project: project2
18208
+ project: project2,
18209
+ token
17968
18210
  });
17969
18211
  }
17970
18212
  async function runStorageStatus(opts) {
@@ -17983,7 +18225,8 @@ async function runStorageStatus(opts) {
17983
18225
  await reportResourceStatus({
17984
18226
  read: () => getStorageStatus(apiUrl, token, project2),
17985
18227
  noun: "object storage",
17986
- project: project2
18228
+ project: project2,
18229
+ token
17987
18230
  });
17988
18231
  }
17989
18232
  async function runAiStatus(opts) {
@@ -18012,7 +18255,7 @@ async function runAiStatus(opts) {
18012
18255
  `
18013
18256
  );
18014
18257
  } catch (err) {
18015
- reportError(err);
18258
+ await reportError(err);
18016
18259
  }
18017
18260
  }
18018
18261
  function isoDay(iso) {
@@ -18052,7 +18295,7 @@ HATCHET_CLIENT_TOKEN will be injected on your next deploy (kryd deploy)` + (stat
18052
18295
  ` : ".\n")
18053
18296
  );
18054
18297
  } catch (err) {
18055
- reportError(err);
18298
+ await reportError(err);
18056
18299
  }
18057
18300
  }
18058
18301
  async function runWorkflowRemove(opts) {
@@ -18072,7 +18315,7 @@ async function runWorkflowRemove(opts) {
18072
18315
  try {
18073
18316
  current = await getWorkflowStatus(apiUrl, token, project2);
18074
18317
  } catch (err) {
18075
- reportError(err);
18318
+ await reportError(err);
18076
18319
  return;
18077
18320
  }
18078
18321
  if (!current.enabled) {
@@ -18097,7 +18340,7 @@ Workflow history, crons and schedules stay until the project is deleted.
18097
18340
  `
18098
18341
  );
18099
18342
  } catch (err) {
18100
- reportError(err);
18343
+ await reportError(err);
18101
18344
  }
18102
18345
  }
18103
18346
  async function runWorkflowStatus(opts) {
@@ -18137,7 +18380,7 @@ async function runWorkflowStatus(opts) {
18137
18380
  );
18138
18381
  }
18139
18382
  } catch (err) {
18140
- reportError(err);
18383
+ await reportError(err);
18141
18384
  }
18142
18385
  }
18143
18386
  async function runWorkflowInit(opts) {
@@ -18160,6 +18403,22 @@ async function runWorkflowInit(opts) {
18160
18403
  }
18161
18404
  const language = opts.language;
18162
18405
  const target = opts.dir ? resolve2(cwd, opts.dir) : cwd;
18406
+ if (!opts.only) {
18407
+ scaffoldContainedWorker({
18408
+ language,
18409
+ target,
18410
+ ...opts.path !== void 0 ? { path: opts.path } : {},
18411
+ ...opts.name !== void 0 ? { name: opts.name } : {}
18412
+ });
18413
+ return;
18414
+ }
18415
+ if (opts.path !== void 0) {
18416
+ process.stderr.write(
18417
+ "--path is for a worker inside an app; with --only the worker IS the repository root.\n"
18418
+ );
18419
+ process.exitCode = 1;
18420
+ return;
18421
+ }
18163
18422
  const workerName = workerNameFrom(opts.name ?? basename2(target));
18164
18423
  const files = scaffoldFiles(language, workerName);
18165
18424
  const clashes = files.map((f) => f.path).filter((p) => existsSync3(join3(target, p)));
@@ -18205,9 +18464,10 @@ Nothing was left behind.
18205
18464
  if (repo.status === "nested") {
18206
18465
  process.stderr.write(
18207
18466
  `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.
18467
+ A worker-only project (--only) has to be its own repository: git would write the deploy
18468
+ remote into the enclosing repo, so \`kryd push\` would push that tree instead.
18210
18469
  The files above were written \u2014 move them to a directory of their own, or run this outside that repository.
18470
+ To add a worker to the app in that repository instead, run \`kryd workflow init\` (without --only) at its root.
18211
18471
  `
18212
18472
  );
18213
18473
  process.exitCode = 1;
@@ -18254,6 +18514,131 @@ HATCHET_CLIENT_TOKEN is injected into your container on deploy; it is never avai
18254
18514
  `
18255
18515
  );
18256
18516
  }
18517
+ function scaffoldContainedWorker(opts) {
18518
+ const { language, target } = opts;
18519
+ if (!directoryIsLinked(target)) {
18520
+ process.stderr.write(
18521
+ "This directory is not linked to a Kryd project, so there is no app to add a worker to.\nLink your app first (`kryd init`), then run this again \u2014 or pass --only for a project that runs nothing but a worker.\n"
18522
+ );
18523
+ process.exitCode = 1;
18524
+ return;
18525
+ }
18526
+ const raw = (opts.path ?? DEFAULT_WORKFLOWS_PATH).trim();
18527
+ const segments = raw.split("/").filter((seg) => seg !== "" && seg !== ".");
18528
+ if (raw.startsWith("/") || raw.includes("\\") || segments.includes("..") || segments.length === 0) {
18529
+ process.stderr.write(
18530
+ `--path must be a folder inside this project (e.g. "${DEFAULT_WORKFLOWS_PATH}"), not "${raw}". For a worker at the repository root, use --only in a directory of its own.
18531
+ `
18532
+ );
18533
+ process.exitCode = 1;
18534
+ return;
18535
+ }
18536
+ const folder = segments.join("/");
18537
+ const declarationPath = join3(target, DECLARATION_FILE);
18538
+ let existing = {};
18539
+ let existingText = null;
18540
+ if (existsSync3(declarationPath)) {
18541
+ existingText = readFileSync3(declarationPath, "utf8");
18542
+ let parsed;
18543
+ try {
18544
+ parsed = JSON.parse(existingText);
18545
+ } catch {
18546
+ parsed = void 0;
18547
+ }
18548
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
18549
+ process.stderr.write(
18550
+ `${DECLARATION_FILE} is not a JSON object, so a worker cannot be added to it. Fix or remove it, then run this again.
18551
+ `
18552
+ );
18553
+ process.exitCode = 1;
18554
+ return;
18555
+ }
18556
+ existing = parsed;
18557
+ if ("kind" in existing) {
18558
+ process.stderr.write(
18559
+ `${DECLARATION_FILE} still uses \`{"kind":"workflow"}\`, which is no longer read. A worker-only repository is \`{"web": false, "workflows": {"path": "."}}\`; an app with workflows in a folder is \`{"workflows": {"path": "workflows"}}\`. Update it, then run this again if you still want a worker folder. Nothing was written.
18560
+ `
18561
+ );
18562
+ process.exitCode = 1;
18563
+ return;
18564
+ }
18565
+ if ("workflows" in existing || existing["web"] === false) {
18566
+ process.stderr.write(
18567
+ `${DECLARATION_FILE} already declares a worker for this project \u2014 a project has one. Nothing was written.
18568
+ `
18569
+ );
18570
+ process.exitCode = 1;
18571
+ return;
18572
+ }
18573
+ }
18574
+ const workerName = workerNameFrom(opts.name ?? basename2(target));
18575
+ const files = workerFiles(language, workerName).map((f) => ({
18576
+ path: `${folder}/${f.path}`,
18577
+ contents: f.contents
18578
+ }));
18579
+ const clashes = files.map((f) => f.path).filter((p) => existsSync3(join3(target, p)));
18580
+ if (clashes.length > 0) {
18581
+ process.stderr.write(
18582
+ `Refusing to overwrite: ${clashes.join(", ")}.
18583
+ Nothing was written. Pick another folder with --path <folder>.
18584
+ `
18585
+ );
18586
+ process.exitCode = 1;
18587
+ return;
18588
+ }
18589
+ const declaration = { ...existing, workflows: { path: folder } };
18590
+ const written = [];
18591
+ try {
18592
+ for (const file2 of files) {
18593
+ const full = join3(target, file2.path);
18594
+ mkdirSync2(dirname2(full), { recursive: true });
18595
+ writeFileSync2(full, file2.contents);
18596
+ written.push(full);
18597
+ }
18598
+ writeFileSync2(declarationPath, `${JSON.stringify(declaration, null, 2)}
18599
+ `);
18600
+ } catch (err) {
18601
+ for (const path of written.reverse()) {
18602
+ try {
18603
+ rmSync2(path);
18604
+ } catch {
18605
+ }
18606
+ }
18607
+ try {
18608
+ if (existingText === null) rmSync2(declarationPath, { force: true });
18609
+ else writeFileSync2(declarationPath, existingText);
18610
+ } catch {
18611
+ }
18612
+ process.stderr.write(
18613
+ `Could not write the scaffold (${err instanceof Error ? err.message : String(err)}).
18614
+ Nothing was left behind.
18615
+ `
18616
+ );
18617
+ process.exitCode = 1;
18618
+ return;
18619
+ }
18620
+ const next = [];
18621
+ const first = firstCommand(language);
18622
+ if (first) next.push(`(cd ${folder} && ${first})`);
18623
+ next.push(
18624
+ "kryd workflow add",
18625
+ `git add ${DECLARATION_FILE} ${folder} && git commit -m "add workflows"`,
18626
+ "kryd push"
18627
+ );
18628
+ process.stdout.write(
18629
+ `Scaffolded a ${language} workflow worker in ${folder}/:
18630
+ ` + files.map((f) => ` ${f.path}
18631
+ `).join("") + `and declared it in ${DECLARATION_FILE}: ${JSON.stringify({ workflows: { path: folder } })}
18632
+
18633
+ Every production push now builds your app AND this worker, and goes live only when both are healthy. Previews run the app only.
18634
+ Next: ${next.map((c) => `\`${c}\``).join(", then ")}.
18635
+ HATCHET_CLIENT_TOKEN is injected into your app and your worker on deploy \u2014 your app triggers tasks with it; it is never available locally.
18636
+ ` + // The app is still built (and type-checked) from the root, so a tsconfig that includes every
18637
+ // file would compile the worker too — against an SDK the app does not depend on.
18638
+ (existsSync3(join3(target, "tsconfig.json")) ? `Add "${folder}" to the "exclude" of your tsconfig.json, so your app's build does not type-check the worker.
18639
+ ` : "")
18640
+ );
18641
+ }
18257
18642
  async function runStorageRemove(opts) {
18258
18643
  const apiUrl = resolveApiUrl(opts.apiUrl);
18259
18644
  const token = loadConfig().token;
@@ -18290,7 +18675,7 @@ async function runStorageRemove(opts) {
18290
18675
  retryCmd: `kryd storage remove ${project2}`
18291
18676
  });
18292
18677
  } catch (err) {
18293
- reportError(err);
18678
+ await reportError(err);
18294
18679
  }
18295
18680
  }
18296
18681
  function splitKeyValue(spec) {
@@ -18329,7 +18714,7 @@ async function runProjectList(opts) {
18329
18714
  try {
18330
18715
  projects = await listProjects(apiUrl, token);
18331
18716
  } catch (err) {
18332
- reportError(err);
18717
+ await reportError(err);
18333
18718
  return;
18334
18719
  }
18335
18720
  if (projects.length === 0) {
@@ -18437,7 +18822,7 @@ Set one with \`kryd env set KEY --stdin\`, or attach a database, storage or the
18437
18822
  out += "\nValues are never shown. This is the stored set \u2014 runtime changes reach your container at its next deploy (run `kryd redeploy` to apply them now)" + (buildVars.length > 0 ? ", and build-time changes at its next build.\n" : ".\n");
18438
18823
  process.stdout.write(out);
18439
18824
  } catch (err) {
18440
- reportError(err);
18825
+ await reportError(err);
18441
18826
  }
18442
18827
  }
18443
18828
  function toDotenvLine(key, value) {
@@ -18506,7 +18891,7 @@ ${body}
18506
18891
  "These are your own values, read back from Kryd. Managed values (DATABASE_URL, storage, the AI gateway) are never pulled.\n"
18507
18892
  );
18508
18893
  } catch (err) {
18509
- reportError(err);
18894
+ await reportError(err);
18510
18895
  }
18511
18896
  }
18512
18897
  async function resolveEnvValue(inline, opts) {
@@ -18639,7 +19024,7 @@ async function runEnvSet(opts) {
18639
19024
  ${NEXT_BUILD_NOTE}` : NEXT_DEPLOY_NOTE)
18640
19025
  );
18641
19026
  } catch (err) {
18642
- reportError(err);
19027
+ await reportError(err);
18643
19028
  }
18644
19029
  }
18645
19030
  async function runEnvRemove(opts) {
@@ -18699,7 +19084,7 @@ Run \`kryd env list\` to see what is set. (If you meant the runtime variable of
18699
19084
  return;
18700
19085
  }
18701
19086
  } catch (err) {
18702
- reportError(err);
19087
+ await reportError(err);
18703
19088
  return;
18704
19089
  }
18705
19090
  }
@@ -18737,21 +19122,32 @@ Run \`kryd env list\` to see what is set. (If you meant the runtime variable of
18737
19122
  `)
18738
19123
  );
18739
19124
  } catch (err) {
18740
- reportError(err);
19125
+ await reportError(err);
18741
19126
  }
18742
19127
  }
18743
- var CLI_VERSION = true ? "0.8.0" : "0.0.0-dev";
19128
+ var CLI_VERSION = true ? "0.9.0" : "0.0.0-dev";
18744
19129
  var program = new Command();
18745
19130
  program.name("kryd").description("Kryd CLI").version(CLI_VERSION);
19131
+ program.hook("postAction", async () => {
19132
+ await backfillLinkAccount();
19133
+ });
18746
19134
  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(
18747
19135
  "--token <token>",
18748
19136
  "supply a token instead of the browser flow (prefer $KRYD_TOKEN \u2014 flags are visible in `ps`)"
18749
19137
  ).option("--api-url <url>", "control-plane API base URL").option("--dashboard-url <url>", "dashboard base URL for the browser approve page (default app.kryd.eu)").action((opts) => runLogin(opts));
18750
19138
  program.command("whoami").description("Show the current account").option("--api-url <url>", "control-plane API base URL").action((opts) => runWhoami(opts));
18751
19139
  program.command("logout").description("Clear the stored token").action(() => runLogout());
19140
+ program.command("git-credential <operation>", { hidden: true }).description("git credential helper (invoked by git, not by you)").option("--api-url <url>", "control-plane API base URL").action(async (operation, opts) => {
19141
+ const chunks = [];
19142
+ for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
19143
+ await runGitCredential(operation, {
19144
+ stdin: Buffer.concat(chunks).toString("utf8"),
19145
+ ...opts.apiUrl ? { apiUrl: opts.apiUrl } : {}
19146
+ });
19147
+ });
18752
19148
  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(
18753
19149
  "--framework <framework>",
18754
- "react-router | nextjs | vite-spa | node (auto-detected if omitted; `workflow` comes from a committed kryd.json)"
19150
+ 'react-router | nextjs | vite-spa | node (auto-detected if omitted; `workflow` comes from a committed kryd.json with "web": false)'
18755
19151
  ).option(
18756
19152
  "--tenant <slug>",
18757
19153
  // KRYD-265 / KRYD-188: this used to say "claimed once on first init", which was false — signup
@@ -18764,15 +19160,23 @@ program.command("init").description("Link this project's repo + register its pus
18764
19160
  "create the repository PUBLIC \u2014 readable by anyone signed in to the forge (default: private). Set at creation only"
18765
19161
  ).option("--api-url <url>", "control-plane API base URL").action((opts) => runInit(opts));
18766
19162
  program.command("logs [target]").description(
18767
- "Follow logs. Default: a deploy's build/deploy logs ([target]=deployment id, latest if omitted). With --runtime: a container's stdout/stderr \u2014 [target]=a project id tails its live app, a deployment id (dpl_\u2026) tails THAT deploy's container (incl. a failed one, to see why it crashed)."
19163
+ "Follow logs. Default: a deploy's build/deploy logs ([target]=deployment id, latest if omitted). With --runtime: a container's stdout/stderr \u2014 [target]=a project id tails its live app, a deployment id (dpl_\u2026) tails THAT deploy's container (incl. a failed one, to see why it crashed). With --worker: a project's workflow worker, also when the project has a web app."
18768
19164
  ).option(
18769
19165
  "--runtime",
18770
19166
  "stream a container's runtime stdout/stderr instead of build/deploy logs \u2014 the live app (project id) or one deploy's container (dpl_ id)"
19167
+ ).option(
19168
+ "--worker",
19169
+ "with a project id: stream its workflow WORKER's stdout/stderr (implies --runtime) \u2014 for a project with a web app too, --runtime shows the web app"
18771
19170
  ).option(
18772
19171
  "--since <dur>",
18773
- "with --runtime: backfill this window before going live (e.g. 30m, 2h, 1d; max 7d)"
19172
+ "with --runtime/--worker: backfill this window before going live (e.g. 30m, 2h, 1d; max 7d)"
18774
19173
  ).option("--api-url <url>", "control-plane API base URL").action(
18775
- (target, opts) => opts.runtime ? runRuntimeLogs({ project: target, since: opts.since, apiUrl: opts.apiUrl }) : runLogs({ apiUrl: opts.apiUrl, deployment: target })
19174
+ (target, opts) => opts.runtime || opts.worker ? runRuntimeLogs({
19175
+ project: target,
19176
+ since: opts.since,
19177
+ apiUrl: opts.apiUrl,
19178
+ ...opts.worker ? { worker: true } : {}
19179
+ }) : runLogs({ apiUrl: opts.apiUrl, deployment: target })
18776
19180
  );
18777
19181
  program.command("push [branch]").description(
18778
19182
  "Push to the kryd remote and follow the deploy it triggers (defaults to the current branch)"
@@ -18841,11 +19245,11 @@ workflow.command("add [project]").description("Give the project a workflow tenan
18841
19245
  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
19246
  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
19247
  workflow.command("init").description(
18844
- "Scaffold a workflow worker here (with the health listener Kryd's deploy gate needs) and link it"
19248
+ "Add a workflow worker to the linked app here (in ./workflows, declared in kryd.json); --only scaffolds a worker-only project instead and links it"
18845
19249
  ).requiredOption(
18846
19250
  "--language <language>",
18847
19251
  `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));
19252
+ ).option("--path <folder>", "the worker's folder inside the app (default: workflows)").option("--only", "a project that runs ONLY a worker: scaffold it at the root, git init, and link").option("--dir <path>", "the project directory, instead of the current one").option("--name <name>", "worker name (and, with --only, project name); defaults to the directory name").option("--no-link", "with --only: only write the files; do not create a Kryd project").option("--api-url <url>", "control-plane API base URL").action((opts) => runWorkflowInit(opts));
18849
19253
  function invokedDirectly() {
18850
19254
  const entry = process.argv[1];
18851
19255
  if (!entry) return false;
@@ -18865,6 +19269,7 @@ if (invokedDirectly()) {
18865
19269
  });
18866
19270
  }
18867
19271
  export {
19272
+ backfillLinkAccount,
18868
19273
  program,
18869
19274
  runAiAdd,
18870
19275
  runAiRemove,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kryd/cli",
3
- "version": "0.8.0",
3
+ "version": "0.9.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",