@sanoma-ai/workflows 0.1.0 → 0.2.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 (99) hide show
  1. package/README.md +72 -21
  2. package/dist/apply-steps.d.ts +6 -0
  3. package/dist/apply-steps.js +93 -0
  4. package/dist/apply-steps.js.map +1 -0
  5. package/dist/apply.d.ts +197 -0
  6. package/dist/apply.js +470 -0
  7. package/dist/apply.js.map +1 -0
  8. package/dist/call.d.ts +4 -0
  9. package/dist/call.js +19 -4
  10. package/dist/call.js.map +1 -1
  11. package/dist/client.d.ts +38 -2
  12. package/dist/client.js +67 -10
  13. package/dist/client.js.map +1 -1
  14. package/dist/config.d.ts +3 -3
  15. package/dist/config.js +33 -16
  16. package/dist/config.js.map +1 -1
  17. package/dist/credentials.d.ts +25 -0
  18. package/dist/credentials.js +73 -5
  19. package/dist/credentials.js.map +1 -1
  20. package/dist/datafile.d.ts +11 -2
  21. package/dist/datafile.js +56 -2
  22. package/dist/datafile.js.map +1 -1
  23. package/dist/describe.d.ts +11 -5
  24. package/dist/describe.js +13 -5
  25. package/dist/describe.js.map +1 -1
  26. package/dist/drift.d.ts +64 -12
  27. package/dist/drift.js +118 -41
  28. package/dist/drift.js.map +1 -1
  29. package/dist/expectations.d.ts +2 -2
  30. package/dist/expectations.js +28 -3
  31. package/dist/expectations.js.map +1 -1
  32. package/dist/host.d.ts +56 -0
  33. package/dist/host.js +45 -0
  34. package/dist/host.js.map +1 -0
  35. package/dist/index.d.ts +6 -2
  36. package/dist/index.js +5 -1
  37. package/dist/index.js.map +1 -1
  38. package/dist/lockfile.d.ts +36 -0
  39. package/dist/lockfile.js +69 -0
  40. package/dist/lockfile.js.map +1 -0
  41. package/dist/op.d.ts +3 -0
  42. package/dist/op.js.map +1 -1
  43. package/dist/resource.d.ts +188 -22
  44. package/dist/resource.js +173 -28
  45. package/dist/resource.js.map +1 -1
  46. package/dist/resources.d.ts +18 -3
  47. package/dist/resources.js +53 -6
  48. package/dist/resources.js.map +1 -1
  49. package/dist/run.d.ts +14 -0
  50. package/dist/run.js +6 -1
  51. package/dist/run.js.map +1 -1
  52. package/dist/sandbox.js +5 -1
  53. package/dist/sandbox.js.map +1 -1
  54. package/dist/scenario.d.ts +12 -3
  55. package/dist/scenario.js +33 -8
  56. package/dist/scenario.js.map +1 -1
  57. package/dist/secret.d.ts +42 -0
  58. package/dist/secret.js +58 -0
  59. package/dist/secret.js.map +1 -0
  60. package/dist/shared.d.ts +8 -0
  61. package/dist/shared.js +7 -0
  62. package/dist/shared.js.map +1 -1
  63. package/dist/source.d.ts +58 -0
  64. package/dist/source.js +54 -0
  65. package/dist/source.js.map +1 -0
  66. package/dist/version.d.ts +1 -1
  67. package/dist/version.js +1 -1
  68. package/dist/worker.d.ts +3 -0
  69. package/dist/worker.js +13 -4
  70. package/dist/worker.js.map +1 -1
  71. package/dist/writer.d.ts +35 -0
  72. package/dist/writer.js +201 -0
  73. package/dist/writer.js.map +1 -0
  74. package/package.json +6 -1
  75. package/src/apply-steps.ts +110 -0
  76. package/src/apply.ts +626 -0
  77. package/src/call.ts +19 -4
  78. package/src/client.ts +83 -13
  79. package/src/config.ts +48 -25
  80. package/src/credentials.ts +90 -5
  81. package/src/datafile.ts +76 -4
  82. package/src/describe.ts +36 -16
  83. package/src/drift.ts +155 -45
  84. package/src/expectations.ts +30 -4
  85. package/src/host.ts +96 -0
  86. package/src/index.ts +24 -1
  87. package/src/lockfile.ts +95 -0
  88. package/src/op.ts +3 -0
  89. package/src/resource.ts +270 -47
  90. package/src/resources.ts +63 -8
  91. package/src/run.ts +15 -0
  92. package/src/sandbox.ts +5 -1
  93. package/src/scenario.ts +50 -13
  94. package/src/secret.ts +92 -0
  95. package/src/shared.ts +10 -0
  96. package/src/source.ts +82 -0
  97. package/src/version.ts +1 -1
  98. package/src/worker.ts +16 -5
  99. package/src/writer.ts +225 -0
package/src/writer.ts ADDED
@@ -0,0 +1,225 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { FormatConfig } from "oxfmt";
4
+ import { type Node, parse } from "./ast.ts";
5
+ import {
6
+ type DataFileExport,
7
+ type DataFileImport,
8
+ type DataValue,
9
+ isReference,
10
+ keyName,
11
+ readDataFile,
12
+ SECRET_ENTRIES,
13
+ } from "./datafile.ts";
14
+ import { isSecret } from "./secret.ts";
15
+
16
+ // The data-file writer: what the app's Adopt, and setting up a company, write into the files
17
+ // `datafile.ts` reads. A file is changed in place, one declaration at a time, and everything
18
+ // outside that declaration is kept byte for byte; what is written is formatted with oxfmt, under
19
+ // the repository's own options, so it reads as if a person had written it and formatted it.
20
+
21
+ /** A whole data file to render: its imports and its resources, in order. */
22
+ export interface DataFileContent {
23
+ imports: readonly Pick<DataFileImport, "source" | "imported" | "local">[];
24
+ exports: readonly Pick<DataFileExport, "name" | "vendor" | "type" | "desired">[];
25
+ }
26
+
27
+ /**
28
+ * oxfmt's options from `<root>/.oxfmtrc.json`, what `oxfmt` itself reads there, less what only
29
+ * its CLI uses (`ignorePatterns`, `overrides`); none, oxfmt's defaults, without the file.
30
+ */
31
+ export function formatOptions(root: string): FormatConfig {
32
+ let text: string;
33
+ try {
34
+ text = readFileSync(join(root, ".oxfmtrc.json"), "utf8");
35
+ } catch (err) {
36
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return {};
37
+ throw err;
38
+ }
39
+ const { $schema: _, ignorePatterns: __, overrides: ___, ...options } = JSON.parse(text) as Record<string, unknown>;
40
+ return options as FormatConfig;
41
+ }
42
+
43
+ /**
44
+ * `source`, a data file's text, with fields of its resource `exportName` set to new values:
45
+ * `fields` by dotted path, a list item's with its index, as a drift report names them
46
+ * (`required_pull_request_reviews.0.dismiss_stale_reviews`). A field the declaration does not
47
+ * have is added at the end of the object that should hold it. Only that `export const` changes:
48
+ * it is formatted with oxfmt under `options` (`formatOptions`), and every other byte of the file
49
+ * stays as it was.
50
+ *
51
+ * A value naming a secret adds `import { secret } from "sanoma/resources"` after the file's
52
+ * imports when it has none.
53
+ *
54
+ * Throws when the file does not parse, declares no such resource, or a path cannot be set: it
55
+ * runs through a value that is not an object or a list, or ends at a reference to another
56
+ * resource (a person changes a reference; a vendor's value never replaces one).
57
+ */
58
+ export async function setFields(
59
+ source: string,
60
+ exportName: string,
61
+ fields: Readonly<Record<string, DataValue>>,
62
+ options: FormatConfig = {},
63
+ ): Promise<string> {
64
+ let text = source;
65
+ // One field at a time, each against the file as the last left it: no offset goes stale.
66
+ for (const [path, value] of Object.entries(fields)) {
67
+ const { start, end, replacement } = editAt(declarationOf(text, exportName).object, path, value);
68
+ text = text.slice(0, start) + replacement + text.slice(end);
69
+ }
70
+ if (Object.values(fields).some(holdsSecret)) text = withSecretImport(text);
71
+ // The declaration on its own: a top-level statement formats the same in its file as alone.
72
+ const { start, end } = declarationOf(text, exportName).statement;
73
+ const formatted = await format(text.slice(start, end), options);
74
+ return text.slice(0, start) + formatted.trimEnd() + text.slice(end);
75
+ }
76
+
77
+ /** What a data file imports `secret` from when it names one (`readDataFile` lists no such import). */
78
+ const SECRET_IMPORT = { source: "sanoma/resources", imported: "secret", local: "secret" };
79
+
80
+ /**
81
+ * A whole data file: the imports, one per source, then each resource as `export const`, then
82
+ * `export default` naming them all, formatted with oxfmt under `options`. `readDataFile` reads it
83
+ * back as it was given: what `readDataFile` returns renders the file again. A file whose values
84
+ * name a secret also imports `secret` from "sanoma/resources".
85
+ */
86
+ export async function renderDataFile({ imports, exports }: DataFileContent, options: FormatConfig = {}) {
87
+ const names = new Map<string, string[]>();
88
+ const hasSecret = imports.some((i) => i.local === SECRET_IMPORT.local);
89
+ const all = !hasSecret && exports.some((e) => holdsSecret(e.desired)) ? [...imports, SECRET_IMPORT] : imports;
90
+ for (const { source, imported, local } of all) {
91
+ const list = names.get(source) ?? [];
92
+ list.push(imported === local ? local : `${imported} as ${local}`);
93
+ names.set(source, list);
94
+ }
95
+ const parts = [
96
+ [...names].map(([source, list]) => `import { ${list.join(", ")} } from ${JSON.stringify(source)};`).join("\n"),
97
+ // The top-level object on lines of its own, so oxfmt keeps it expanded as people write it.
98
+ ...exports.map(({ name, vendor, type, desired }) => {
99
+ const body = Object.entries(desired).map(([key, value]) => `${keyOf(key)}: ${literal(value)},\n`);
100
+ return `export const ${name} = ${vendor}.${type}({${body.length ? `\n${body.join("")}` : ""}});`;
101
+ }),
102
+ `export default [${exports.map((e) => e.name).join(", ")}];`,
103
+ ];
104
+ return format(`${parts.filter(Boolean).join("\n\n")}\n`, options);
105
+ }
106
+
107
+ /** `text` with `secret` imported after its imports, unless it imports it already. */
108
+ function withSecretImport(text: string): string {
109
+ const imports = parse("/data-file.ts", text).program.body.filter((s: Node) => s.type === "ImportDeclaration");
110
+ const has = imports.some(
111
+ (s: Node) =>
112
+ SECRET_ENTRIES.has(s.source.value) &&
113
+ s.specifiers.some((sp: Node) => (sp.imported?.name ?? sp.imported?.value) === "secret"),
114
+ );
115
+ if (has) return text;
116
+ const at = imports.at(-1)?.end ?? 0;
117
+ const line = `import { ${SECRET_IMPORT.local} } from ${JSON.stringify(SECRET_IMPORT.source)};`;
118
+ return at ? `${text.slice(0, at)}\n${line}${text.slice(at)}` : `${line}\n${text}`;
119
+ }
120
+
121
+ /** The `export const` of the resource `name` in `text`, and the object literal its constructor takes. */
122
+ function declarationOf(text: string, name: string): { statement: Node; object: Node } {
123
+ // The name is never resolved, and a relative import is checked against the file's own directory.
124
+ const filename = "/data-file.ts";
125
+ const { program, errors } = parse(filename, text);
126
+ if (errors.length) throw new Error(`The data file does not parse: ${errors[0]!.message}`);
127
+ const found = readDataFile(program, filename).exports.find((e) => e.name === name);
128
+ if (!found) throw new Error(`The data file declares no resource ${name}`);
129
+ const statement: Node = program.body.find((s) => s.start === found.start);
130
+ return { statement, object: statement.declaration.declarations[0].init.arguments[0] };
131
+ }
132
+
133
+ /**
134
+ * What setting `path` to `value` in `object` replaces, and with what. A key may hold a dot
135
+ * (`metadata: { "app.owner": … }`), and a drift report's path does not say so: at each object,
136
+ * the longest run of the path's segments that names a field is that field.
137
+ */
138
+ function editAt(object: Node, path: string, value: DataValue): { start: number; end: number; replacement: string } {
139
+ const keys = path.split(".");
140
+ let node = object;
141
+ for (let i = 0; i < keys.length;) {
142
+ if (node.type === "ObjectExpression") {
143
+ const names = new Map<string, Node>(node.properties.map((p: Node) => [fieldName(p), p]));
144
+ let j = keys.length;
145
+ while (j > i && !names.has(keys.slice(i, j).join("."))) j--;
146
+ if (j === i) return added(node, keys.slice(i), path, value);
147
+ node = names.get(keys.slice(i, j).join("."))!.value;
148
+ i = j;
149
+ } else if (node.type === "ArrayExpression" && /^\d+$/.test(keys[i]!) && node.elements[Number(keys[i])]) {
150
+ node = node.elements[Number(keys[i])];
151
+ i++;
152
+ } else {
153
+ const at = keys.slice(0, i).join(".");
154
+ throw new Error(`Cannot set ${path}: ${at} is not an object or a list with item ${keys[i]}`);
155
+ }
156
+ }
157
+ const named = referenceIn(node);
158
+ if (named) {
159
+ throw new Error(`Cannot set ${path}: it names the resource ${named}; change a reference in the file itself`);
160
+ }
161
+ return { start: node.start, end: node.end, replacement: literal(value) };
162
+ }
163
+
164
+ /** The first resource a value names, itself or anywhere inside it: a vendor's value never replaces one. */
165
+ function referenceIn(node: Node): string | undefined {
166
+ if (node.type === "Identifier") return node.name;
167
+ const items =
168
+ node.type === "ArrayExpression" ? node.elements : node.type === "ObjectExpression" ? node.properties : [];
169
+ for (const item of items) {
170
+ const named = item && referenceIn(item.type === "Property" ? item.value : item);
171
+ if (named) return named;
172
+ }
173
+ return undefined;
174
+ }
175
+
176
+ /**
177
+ * The edit that adds `keys` (the first missing, then any under it) with `value` at the end of
178
+ * `object`. Only objects are made under it: a list item's index there is refused.
179
+ */
180
+ function added(object: Node, [key, ...under]: string[], path: string, value: DataValue) {
181
+ const index = under.find((k) => /^\d+$/.test(k));
182
+ if (index !== undefined) throw new Error(`Cannot set ${path}: the file has no list to hold item ${index}`);
183
+ const nested = under.reduceRight<DataValue>((v, k) => Object.fromEntries([[k, v]]), value);
184
+ const field = `${keyOf(key!)}: ${literal(nested)}`;
185
+ const last = object.properties.at(-1);
186
+ return last
187
+ ? { start: last.end, end: last.end, replacement: `, ${field}` }
188
+ : { start: object.start, end: object.end, replacement: `{ ${field} }` };
189
+ }
190
+
191
+ /** A property's name, as `readDataFile` reads it. */
192
+ const fieldName = (p: Node): string | undefined => (p.type === "Property" && !p.computed ? keyName(p.key) : undefined);
193
+
194
+ /** A field's name in source: bare when it can be, else a string. */
195
+ const keyOf = (key: string) => (/^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key));
196
+
197
+ /** True when a value names a secret, itself or anywhere inside it. */
198
+ const holdsSecret = (value: DataValue): boolean =>
199
+ isSecret(value) ||
200
+ (typeof value === "object" && value !== null && !isReference(value) && Object.values(value).some(holdsSecret));
201
+
202
+ /** A value in the data-file subset, as source, on one line: oxfmt lays it out. */
203
+ function literal(value: DataValue): string {
204
+ if (isReference(value)) return value.name;
205
+ if (isSecret(value)) return `secret(${JSON.stringify(value.$secret)}${value.generate ? ", { generate: true }" : ""})`;
206
+ if (value === null || typeof value === "boolean" || typeof value === "string") return JSON.stringify(value);
207
+ if (typeof value === "number") {
208
+ if (!Number.isFinite(value)) throw new Error(`${value} cannot be written in a data file: write a finite number`);
209
+ return Object.is(value, -0) ? "0" : String(value);
210
+ }
211
+ if (Array.isArray(value)) return `[${value.map(literal).join(", ")}]`;
212
+ if (typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {
213
+ const fields = Object.entries(value).map(([key, v]) => `${keyOf(key)}: ${literal(v)}`);
214
+ return fields.length ? `{ ${fields.join(", ")} }` : "{}";
215
+ }
216
+ throw new Error(`${String(value)} cannot be written in a data file: ${typeof value} is not a data value`);
217
+ }
218
+
219
+ /** `text` formatted by oxfmt, loaded only when a file is written. */
220
+ async function format(text: string, options: FormatConfig): Promise<string> {
221
+ const oxfmt = await import("oxfmt");
222
+ const { code, errors } = await oxfmt.format("data-file.ts", text, options);
223
+ if (errors.length) throw new Error(`oxfmt could not format the data file: ${errors[0]!.message}`);
224
+ return code;
225
+ }