@sanoma-ai/workflows 0.11.3 → 0.11.4

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 (57) hide show
  1. package/README.md +32 -30
  2. package/dist/apply-steps.d.ts +4 -2
  3. package/dist/apply-steps.js +5 -3
  4. package/dist/apply-steps.js.map +1 -1
  5. package/dist/apply.d.ts +6 -1
  6. package/dist/apply.js +55 -20
  7. package/dist/apply.js.map +1 -1
  8. package/dist/config.d.ts +4 -0
  9. package/dist/config.js +52 -5
  10. package/dist/config.js.map +1 -1
  11. package/dist/constructors.d.ts +1 -1
  12. package/dist/definitions.d.ts +5 -0
  13. package/dist/definitions.js.map +1 -1
  14. package/dist/describe.d.ts +1 -1
  15. package/dist/describe.js +16 -11
  16. package/dist/describe.js.map +1 -1
  17. package/dist/drift.d.ts +7 -0
  18. package/dist/drift.js +15 -3
  19. package/dist/drift.js.map +1 -1
  20. package/dist/host-derive.d.ts +145 -0
  21. package/dist/host-derive.js +403 -0
  22. package/dist/host-derive.js.map +1 -0
  23. package/dist/host.d.ts +70 -5
  24. package/dist/host.js +56 -1
  25. package/dist/host.js.map +1 -1
  26. package/dist/index.d.ts +5 -4
  27. package/dist/index.js +5 -4
  28. package/dist/index.js.map +1 -1
  29. package/dist/resource.d.ts +8 -1
  30. package/dist/resource.js +11 -0
  31. package/dist/resource.js.map +1 -1
  32. package/dist/resources.js +1 -1
  33. package/dist/resources.js.map +1 -1
  34. package/dist/sites.d.ts +6 -0
  35. package/dist/sites.js +28 -4
  36. package/dist/sites.js.map +1 -1
  37. package/dist/source.d.ts +12 -5
  38. package/dist/source.js +32 -13
  39. package/dist/source.js.map +1 -1
  40. package/dist/wakeup-type.d.ts +1 -1
  41. package/dist/wakeup-type.js +1 -1
  42. package/dist/wakeup-type.js.map +1 -1
  43. package/package.json +1 -1
  44. package/src/apply-steps.ts +5 -4
  45. package/src/apply.ts +87 -24
  46. package/src/config.ts +59 -5
  47. package/src/definitions.ts +5 -0
  48. package/src/describe.ts +23 -10
  49. package/src/drift.ts +16 -3
  50. package/src/host-derive.ts +492 -0
  51. package/src/host.ts +101 -5
  52. package/src/index.ts +20 -2
  53. package/src/resource.ts +33 -2
  54. package/src/resources.ts +1 -1
  55. package/src/sites.ts +33 -3
  56. package/src/source.ts +35 -12
  57. package/src/wakeup-type.ts +1 -1
package/src/index.ts CHANGED
@@ -59,6 +59,7 @@ export {
59
59
  type ResourceFields,
60
60
  type ResourceOps,
61
61
  type ResourceSpec,
62
+ valueAt,
62
63
  withoutWriteOnly,
63
64
  } from "./resource.ts";
64
65
  export { type DnsRecord, dnsRecord } from "./dns.ts";
@@ -190,6 +191,7 @@ export {
190
191
  type Draft,
191
192
  draftBranch,
192
193
  type DraftDiff,
194
+ defaultBranch,
193
195
  draftOf,
194
196
  type HostEnforcement,
195
197
  type PullRequest,
@@ -204,7 +206,23 @@ export {
204
206
  type SourceHost,
205
207
  } from "./source.ts";
206
208
  export type { HostCapabilities, HostLifetime, HostOutputs, HostSpec } from "./host.ts";
207
- export { wakeUrlOf } from "./host.ts";
209
+ export { RUNTIME_IMAGE, RUNTIME_PORT, runtimeImage, sourceKeyId, UNNAMED, wakeUrlOf } from "./host.ts";
210
+ export {
211
+ cloneUrl,
212
+ deriveHosts,
213
+ type HostFacts,
214
+ hostFacts,
215
+ DEPLOY_KEY_TITLE,
216
+ DEPLOY_KEY_TYPE,
217
+ GITHUB_APP,
218
+ githubRepository,
219
+ HOST_SIGNING_KEY,
220
+ installedSanoma,
221
+ originUrl,
222
+ SOURCE_KEY,
223
+ sourceRepository,
224
+ wokenBy,
225
+ } from "./host-derive.ts";
208
226
  export {
209
227
  checkDatabase,
210
228
  type DatabaseCapabilities,
@@ -326,7 +344,7 @@ export {
326
344
  export { checkSecretStore, type SecretStoreCheck } from "./secret-store-check.ts";
327
345
 
328
346
  export { postgresSecretStore } from "./credentials.ts";
329
- export { deploymentKey, signingKeyFor } from "./deployment-key.ts";
347
+ export { deploymentKey, keyBytes, signingKeyFor } from "./deployment-key.ts";
330
348
 
331
349
  export {
332
350
  defineConnection,
package/src/resource.ts CHANGED
@@ -131,6 +131,13 @@ export interface ResourceSpec<S extends z.ZodObject = z.ZodObject> {
131
131
  * Supabase project's health, until it is healthy, then its database URL stored.
132
132
  */
133
133
  afterCreate?: readonly AfterCreate[];
134
+ /**
135
+ * The ids (`<vendor>.<type>`) this type was known by before a rename: a lockfile binding under
136
+ * one of them binds a declaration of this type to the same object, so a rename recreates nothing
137
+ * (`cloudflare.host`, formerly `cloudflare.container`). Apply writes the new id with its next
138
+ * change to that binding.
139
+ */
140
+ formerly?: readonly string[];
134
141
  }
135
142
 
136
143
  /** One operation apply calls after a create (`ResourceSpec.afterCreate`). */
@@ -352,11 +359,20 @@ export interface Resource<
352
359
  Required<
353
360
  Omit<
354
361
  ResourceSpec<S>,
355
- "identity" | "find" | "writable" | "host" | "wakeup" | "database" | "stateless" | "afterCreate" | "vendorless"
362
+ | "identity"
363
+ | "find"
364
+ | "writable"
365
+ | "host"
366
+ | "wakeup"
367
+ | "database"
368
+ | "stateless"
369
+ | "afterCreate"
370
+ | "vendorless"
371
+ | "formerly"
356
372
  >
357
373
  >
358
374
  >,
359
- Readonly<Pick<ResourceSpec<S>, "identity" | "find" | "host" | "wakeup" | "database" | "afterCreate">> {
375
+ Readonly<Pick<ResourceSpec<S>, "identity" | "find" | "host" | "wakeup" | "database" | "afterCreate" | "formerly">> {
360
376
  (
361
377
  desired: Declaring<z.input<S>, R>,
362
378
  options?: { connection?: ConnectionRef<Id extends `${infer V}.${string}` ? V : string> },
@@ -836,6 +852,20 @@ export function defineResource<
836
852
  if (host && pathsIn(desired, new Set([host.revision])).length) {
837
853
  throw new Error(`${what}: leave out ${host.revision}: apply sets it from ${host.inputs.ref}`);
838
854
  }
855
+ // A host that derives is given its secrets and its sites' domains: a data file names neither.
856
+ if (host?.derives) {
857
+ const { secrets, domains } = host.inputs;
858
+ if (secrets !== undefined && pathsIn(desired, new Set([secrets])).length) {
859
+ throw new Error(
860
+ `${what}: leave out ${secrets}: apply gives the host its database's URL, its keys and the connections it deploys with, and no secret is written in a file`,
861
+ );
862
+ }
863
+ if (domains !== undefined && pathsIn(desired, new Set([domains])).length) {
864
+ throw new Error(
865
+ `${what}: leave out ${domains}: a public site's domain is declared on the site, \`defineSite({ public: true, domain })\`, and the host attaches it`,
866
+ );
867
+ }
868
+ }
839
869
  const vendors = pathsIn(desired, owned);
840
870
  if (vendors.length) {
841
871
  throw new Error(
@@ -874,6 +904,7 @@ export function defineResource<
874
904
  stateless: spec.stateless ?? host !== undefined,
875
905
  vendorless: spec.vendorless ?? false,
876
906
  ...(spec.afterCreate && { afterCreate: spec.afterCreate }),
907
+ ...(spec.formerly?.length && { formerly: Object.freeze([...spec.formerly]) }),
877
908
  ops,
878
909
  // A symbol, so `defineConnector` tells a resource type from a group of operations.
879
910
  [RESOURCE]: true as const,
package/src/resources.ts CHANGED
@@ -359,7 +359,7 @@ function declarationsOf(
359
359
  const why =
360
360
  value.connection !== undefined && binding.connection?.$connection !== value.connection.$connection
361
361
  ? `${id} changes the connection of a bound resource: explicitly unbind it before selecting another account`
362
- : binding.type !== value[DECLARED]
362
+ : binding.type !== value[DECLARED] && !types.get(value[DECLARED])?.formerly?.includes(binding.type)
363
363
  ? `${id} is bound to a ${binding.type} in ${LOCKFILE}, but declares a ${value[DECLARED]}`
364
364
  : first !== undefined
365
365
  ? `${id} is bound to the same ${object} as ${first} in ${LOCKFILE}: bind each object once`
package/src/sites.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  apiNameOf,
16
16
  BUILT_IN_WORKFLOWS,
17
17
  DATA_VENDOR,
18
+ DOMAIN_NAME,
18
19
  LOCALE,
19
20
  SITE_NAME,
20
21
  siteKey,
@@ -129,7 +130,9 @@ export function readSites(
129
130
  const read = readSiteFiles(root, dir);
130
131
  const resolved = read.flatMap(({ uses }) => uses.filter((use) => !isAppCollection(use)).map(resolve));
131
132
  const pages = pagesOf(read);
132
- const { problems: optionProblems, ...options } = siteDefinitionOf(root, dir, connectors);
133
+ // Its declared domain is the production host's to attach (`siteDomains`): where the site answers
134
+ // is `domain`, which only the app sets, from the `SANOMA_DOMAINS` that host hands it.
135
+ const { problems: optionProblems, domain: _declared, ...options } = siteDefinitionOf(root, dir, connectors);
133
136
  const calls = resolved.map((r) => r.call);
134
137
  const problems = [
135
138
  ...resolved.flatMap(({ call, problem }) => {
@@ -225,6 +228,21 @@ export function siteIcons(root: string): Record<string, string> {
225
228
  );
226
229
  }
227
230
 
231
+ /**
232
+ * Each public site's domain (`defineSite({ public: true, domain })`) under `root`, by name, for
233
+ * the sites whose `site.ts` gives one without a problem there: what the production host attaches
234
+ * and hands the app as `SANOMA_DOMAINS` (`deriveHosts`). Loaded as `readSites` loads it.
235
+ */
236
+ export function siteDomains(root: string): Record<string, string> {
237
+ const none = new Set<string>();
238
+ return Object.fromEntries(
239
+ sitesUnder(root).flatMap(({ name, dir }) => {
240
+ const { domain } = siteDefinitionOf(root, dir, { vendors: none, logins: none });
241
+ return domain === undefined ? [] : [[name, domain]];
242
+ }),
243
+ );
244
+ }
245
+
228
246
  /**
229
247
  * The sites under `root`, by key (`siteKey`): each folder of `sites/` with a `routes/` whose name
230
248
  * is a URL segment, then each app's `apps/<app>/sites/`, with its folder relative to `root`.
@@ -427,10 +445,14 @@ const SiteDefinitionSchema = z.strictObject({
427
445
  public: z.boolean().optional(),
428
446
  login: z.string().optional(),
429
447
  locales: z.array(z.string()).optional(),
448
+ domain: z
449
+ .string()
450
+ .regex(DOMAIN_NAME, "a host name of two labels or more, lower case, such as `www.acme.example`")
451
+ .optional(),
430
452
  });
431
453
 
432
454
  /** What `site.ts` says of who may see a site: read together or not at all. */
433
- const ACCESS = ["public", "login", "locales"];
455
+ const ACCESS = ["public", "login", "locales", "domain"];
434
456
 
435
457
  /**
436
458
  * What the site in `dir` (relative to `root`) says of itself in its `site.ts`, loaded
@@ -445,6 +467,8 @@ function siteDefinitionOf(
445
467
  dir: string,
446
468
  connectors: SiteConnectors,
447
469
  ): Pick<SiteEntry, "at" | "title" | "icon" | "public" | "login" | "locales"> & {
470
+ /** `defineSite({ domain })`, kept only on a public site. */
471
+ domain?: string;
448
472
  problems: (ResourceProblem & { file: string })[];
449
473
  } {
450
474
  const file = `${dir}/site.ts`;
@@ -474,7 +498,7 @@ function siteDefinitionOf(
474
498
  );
475
499
  if (!parsed.success) return { problems };
476
500
  }
477
- const { title, icon, public: isPublic, login, locales } = parsed.data;
501
+ const { title, icon, public: isPublic, login, locales, domain } = parsed.data;
478
502
  const loginProblem = (): string | undefined => {
479
503
  if (!isPublic) return "a login is for a public site: add `public: true`, or remove `login`";
480
504
  if (!connectors.vendors.has(login!)) return `login names "${login}", which is not a connector in sanoma.config.ts`;
@@ -496,6 +520,11 @@ function siteDefinitionOf(
496
520
  };
497
521
  const localesMessage = locales && localesProblem(locales);
498
522
  if (localesMessage) problems.push(problem(localesMessage));
523
+ const domainMessage =
524
+ domain !== undefined && !isPublic
525
+ ? "a domain is for a public site: add `public: true`, or remove `domain`"
526
+ : undefined;
527
+ if (domainMessage) problems.push(problem(domainMessage));
499
528
  return {
500
529
  at: { file, export: "default" },
501
530
  ...(title !== undefined && { title }),
@@ -503,6 +532,7 @@ function siteDefinitionOf(
503
532
  ...(isPublic && { public: true as const }),
504
533
  ...(login !== undefined && { login }),
505
534
  ...(locales && !localesMessage && { locales }),
535
+ ...(domain !== undefined && !domainMessage && { domain }),
506
536
  problems,
507
537
  };
508
538
  }
package/src/source.ts CHANGED
@@ -130,7 +130,8 @@ export interface CheckResult {
130
130
 
131
131
  /**
132
132
  * The built-in operation `sanoma.source.resolve`: the commit `ref` names in the repository at
133
- * `url` now, by `git ls-remote` (a branch first, then a tag, peeled; a full commit id is itself).
133
+ * `url` now, by `git ls-remote` (a branch first, then a tag, peeled; a full commit id is itself),
134
+ * and the ref; with no `ref`, the repository's default branch (its `HEAD`) and the commit it names.
134
135
  * Apply calls it to fill a host's revision before it plans, and drift to see a merge. `resolveConfig`
135
136
  * registers it when a connector declares a host type.
136
137
  */
@@ -139,10 +140,11 @@ export const sourceOps = defineConnector("sanoma", {
139
140
  resolve: {
140
141
  effect: "read",
141
142
  idempotent: true,
142
- description: "Resolve a branch or tag of a git repository to the commit it names now",
143
- input: z.object({ url: z.string().min(1), ref: z.string().min(1) }),
144
- output: z.object({ revision: z.string() }),
145
- target: ({ url, ref }: { url: string; ref: string }) => `${url}#${ref}`,
143
+ description:
144
+ "Resolve a branch or tag of a git repository, its default branch by default, to the commit it names now",
145
+ input: z.object({ url: z.string().min(1), ref: z.string().min(1).optional() }),
146
+ output: z.object({ revision: z.string(), ref: z.string() }),
147
+ target: ({ url, ref }: { url: string; ref?: string }) => `${url}#${ref ?? "HEAD"}`,
146
148
  },
147
149
  },
148
150
  });
@@ -151,7 +153,10 @@ export const SOURCE_RESOLVE = "sanoma.source.resolve" satisfies typeof sourceOps
151
153
 
152
154
  /** The driver of `sanoma.source.resolve`: git on this machine, with its own credential helpers. */
153
155
  export const sourceDriver = defineDriver(sourceOps, {
154
- source: { resolve: async ({ url, ref }) => ({ revision: await resolveRevision(url, ref) }) },
156
+ source: {
157
+ resolve: async ({ url, ref }) =>
158
+ ref === undefined ? defaultBranch(url) : { revision: await resolveRevision(url, ref), ref },
159
+ },
155
160
  });
156
161
 
157
162
  const COMMIT = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
@@ -159,20 +164,20 @@ const COMMIT = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
159
164
  /**
160
165
  * The fake of `sanoma.source.resolve`, which a sandbox run calls (`sandboxDrivers`): no git and no
161
166
  * network, a full commit id is itself as the driver has it, and any other ref the SHA-1 of
162
- * `<url>#<ref>`, so a scenario's revisions are the same on every run.
167
+ * `<url>#<ref>`, so a scenario's revisions are the same on every run; the default branch is `main`.
163
168
  */
164
169
  export const fakeSourceDriver = defineDriver(sourceOps, {
165
170
  source: {
166
- resolve: async ({ url, ref }) => ({
171
+ resolve: async ({ url, ref = "main" }) => ({
167
172
  revision: COMMIT.test(ref) ? ref : createHash("sha1").update(`${url}#${ref}`).digest("hex"),
173
+ ref,
168
174
  }),
169
175
  },
170
176
  });
171
177
 
172
- /** The commit `ref` (a branch, a tag, `HEAD`, a full ref or a commit id) names in the repository at `url`. */
173
- export async function resolveRevision(url: string, ref: string): Promise<string> {
174
- if (COMMIT.test(ref)) return ref;
175
- const { stdout } = await promisify(execFile)("git", ["ls-remote", "--", url, ref, `${ref}^{}`], {
178
+ /** `git ls-remote` of `patterns` in the repository at `url`, with `options`, never prompting for credentials. */
179
+ async function lsRemote(url: string, patterns: string[], options: string[] = []): Promise<string> {
180
+ const { stdout } = await promisify(execFile)("git", ["ls-remote", ...options, "--", url, ...patterns], {
176
181
  // Never a prompt for credentials: a repository that needs them and has none fails.
177
182
  env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
178
183
  timeout: 30_000,
@@ -182,6 +187,24 @@ export async function resolveRevision(url: string, ref: string): Promise<string>
182
187
  cause: err,
183
188
  });
184
189
  });
190
+ return stdout;
191
+ }
192
+
193
+ /** The default branch of the repository at `url` (the branch its `HEAD` names) and the commit it names now. */
194
+ export async function defaultBranch(url: string): Promise<{ revision: string; ref: string }> {
195
+ const stdout = await lsRemote(url, ["HEAD"], ["--symref"]);
196
+ const branch = /^ref: refs\/heads\/(\S+)\tHEAD$/m.exec(stdout)?.[1];
197
+ const revision = /^([0-9a-f]{40,64})\tHEAD$/m.exec(stdout)?.[1];
198
+ if (!branch || !revision) {
199
+ throw new DriverError(`${url} names no default branch`, { retryable: false, vendorCode: "no_such_ref" });
200
+ }
201
+ return { revision, ref: branch };
202
+ }
203
+
204
+ /** The commit `ref` (a branch, a tag, `HEAD`, a full ref or a commit id) names in the repository at `url`. */
205
+ export async function resolveRevision(url: string, ref: string): Promise<string> {
206
+ if (COMMIT.test(ref)) return ref;
207
+ const stdout = await lsRemote(url, [ref, `${ref}^{}`]);
185
208
  const refs = new Map(
186
209
  stdout.split("\n").flatMap((line) => {
187
210
  const [sha, name] = line.split("\t");
@@ -15,7 +15,7 @@ import { defineResource } from "./resource.ts";
15
15
  * The host types a wakeup may name: each one a data file declares a wakeup for. A new host type
16
16
  * is added here. `testhost.app` is `@sanoma-ai/testing`'s, for the conformance kits.
17
17
  */
18
- const HOSTS = ["cloudflare.container", "docker.container", "testhost.app"] as const;
18
+ const HOSTS = ["cloudflare.host", "cloudflare.container", "docker.container", "testhost.app"] as const;
19
19
 
20
20
  /** An instance of the wakeup service, as `GET /wakeup/instances/{id}` shows it. */
21
21
  const wakeupSchema = z.object({