@sanoma-ai/workflows 0.1.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +186 -76
- package/dist/apply-steps.d.ts +6 -0
- package/dist/apply-steps.js +93 -0
- package/dist/apply-steps.js.map +1 -0
- package/dist/apply.d.ts +197 -0
- package/dist/apply.js +470 -0
- package/dist/apply.js.map +1 -0
- package/dist/approvals.d.ts +11 -6
- package/dist/approvals.js +38 -10
- package/dist/approvals.js.map +1 -1
- package/dist/call-workflow.d.ts +16 -0
- package/dist/call-workflow.js +35 -0
- package/dist/call-workflow.js.map +1 -0
- package/dist/call.d.ts +35 -2
- package/dist/call.js +123 -43
- package/dist/call.js.map +1 -1
- package/dist/cedar.d.ts +87 -0
- package/dist/cedar.js +365 -0
- package/dist/cedar.js.map +1 -0
- package/dist/client.d.ts +123 -9
- package/dist/client.js +355 -34
- package/dist/client.js.map +1 -1
- package/dist/collection.d.ts +107 -0
- package/dist/collection.js +306 -0
- package/dist/collection.js.map +1 -0
- package/dist/config.d.ts +42 -14
- package/dist/config.js +109 -28
- package/dist/config.js.map +1 -1
- package/dist/constructors.d.ts +2 -0
- package/dist/constructors.js +5 -0
- package/dist/constructors.js.map +1 -0
- package/dist/credentials.d.ts +25 -0
- package/dist/credentials.js +73 -5
- package/dist/credentials.js.map +1 -1
- package/dist/data.d.ts +2 -0
- package/dist/data.js +3 -0
- package/dist/data.js.map +1 -0
- package/dist/datafile.d.ts +38 -3
- package/dist/datafile.js +166 -13
- package/dist/datafile.js.map +1 -1
- package/dist/db.d.ts +5 -3
- package/dist/db.js +88 -21
- package/dist/db.js.map +1 -1
- package/dist/define.d.ts +10 -4
- package/dist/define.js +1 -5
- package/dist/define.js.map +1 -1
- package/dist/describe.d.ts +54 -8
- package/dist/describe.js +84 -11
- package/dist/describe.js.map +1 -1
- package/dist/direct.d.ts +9 -0
- package/dist/direct.js +11 -0
- package/dist/direct.js.map +1 -0
- package/dist/drift.d.ts +64 -12
- package/dist/drift.js +118 -41
- package/dist/drift.js.map +1 -1
- package/dist/errors.d.ts +16 -1
- package/dist/errors.js +30 -1
- package/dist/errors.js.map +1 -1
- package/dist/expectations.d.ts +4 -4
- package/dist/expectations.js +43 -9
- package/dist/expectations.js.map +1 -1
- package/dist/host.d.ts +56 -0
- package/dist/host.js +45 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +13 -5
- package/dist/index.js +15 -5
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +12 -1
- package/dist/ledger.js +18 -11
- package/dist/ledger.js.map +1 -1
- package/dist/lint.d.ts +5 -4
- package/dist/lint.js +24 -7
- package/dist/lint.js.map +1 -1
- package/dist/lockfile.d.ts +36 -0
- package/dist/lockfile.js +69 -0
- package/dist/lockfile.js.map +1 -0
- package/dist/op.d.ts +9 -0
- package/dist/op.js.map +1 -1
- package/dist/outline.d.ts +2 -0
- package/dist/outline.js +1 -1
- package/dist/outline.js.map +1 -1
- package/dist/paths.d.ts +4 -0
- package/dist/paths.js +17 -3
- package/dist/paths.js.map +1 -1
- package/dist/person.d.ts +61 -0
- package/dist/person.js +69 -0
- package/dist/person.js.map +1 -0
- package/dist/plugin.js +1 -1
- package/dist/plugin.js.map +1 -1
- package/dist/policy.d.ts +51 -55
- package/dist/policy.js +14 -36
- package/dist/policy.js.map +1 -1
- package/dist/resource.d.ts +207 -25
- package/dist/resource.js +184 -30
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +76 -9
- package/dist/resources.js +170 -30
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +31 -1
- package/dist/run.js +6 -1
- package/dist/run.js.map +1 -1
- package/dist/sandbox.js +5 -1
- package/dist/sandbox.js.map +1 -1
- package/dist/scenario.d.ts +25 -9
- package/dist/scenario.js +125 -27
- package/dist/scenario.js.map +1 -1
- package/dist/scenarios.d.ts +10 -6
- package/dist/scenarios.js +8 -4
- package/dist/scenarios.js.map +1 -1
- package/dist/schema.d.ts +32 -0
- package/dist/schema.js +225 -0
- package/dist/schema.js.map +1 -0
- package/dist/sdk.d.ts +34 -0
- package/dist/sdk.js +168 -0
- package/dist/sdk.js.map +1 -0
- package/dist/secret.d.ts +42 -0
- package/dist/secret.js +58 -0
- package/dist/secret.js.map +1 -0
- package/dist/shared.d.ts +188 -4
- package/dist/shared.js +165 -5
- package/dist/shared.js.map +1 -1
- package/dist/sites.d.ts +52 -0
- package/dist/sites.js +174 -0
- package/dist/sites.js.map +1 -0
- package/dist/source.d.ts +58 -0
- package/dist/source.js +54 -0
- package/dist/source.js.map +1 -0
- package/dist/version.d.ts +3 -2
- package/dist/version.js +3 -2
- package/dist/version.js.map +1 -1
- package/dist/worker.d.ts +6 -2
- package/dist/worker.js +37 -8
- package/dist/worker.js.map +1 -1
- package/dist/writer.d.ts +35 -0
- package/dist/writer.js +201 -0
- package/dist/writer.js.map +1 -0
- package/oxlint.json +5 -16
- package/package.json +11 -1
- package/src/apply-steps.ts +110 -0
- package/src/apply.ts +626 -0
- package/src/approvals.ts +49 -13
- package/src/call-workflow.ts +44 -0
- package/src/call.ts +173 -46
- package/src/cedar.ts +493 -0
- package/src/client.ts +450 -49
- package/src/collection.ts +431 -0
- package/src/config.ts +168 -48
- package/src/constructors.ts +4 -0
- package/src/credentials.ts +90 -5
- package/src/data.ts +10 -0
- package/src/datafile.ts +207 -15
- package/src/db.ts +90 -22
- package/src/define.ts +11 -9
- package/src/describe.ts +146 -23
- package/src/direct.ts +15 -0
- package/src/drift.ts +155 -45
- package/src/errors.ts +36 -1
- package/src/expectations.ts +46 -11
- package/src/host.ts +96 -0
- package/src/index.ts +56 -2
- package/src/ledger.ts +35 -17
- package/src/lint.ts +29 -12
- package/src/lockfile.ts +95 -0
- package/src/op.ts +9 -0
- package/src/outline.ts +1 -1
- package/src/paths.ts +18 -3
- package/src/person.ts +75 -0
- package/src/plugin.ts +1 -1
- package/src/policy.ts +59 -63
- package/src/resource.ts +302 -52
- package/src/resources.ts +226 -30
- package/src/run.ts +33 -1
- package/src/sandbox.ts +5 -1
- package/src/scenario.ts +175 -43
- package/src/scenarios.ts +15 -8
- package/src/schema.ts +261 -0
- package/src/sdk.ts +211 -0
- package/src/secret.ts +92 -0
- package/src/shared.ts +279 -6
- package/src/sites.ts +220 -0
- package/src/source.ts +82 -0
- package/src/version.ts +3 -2
- package/src/worker.ts +45 -11
- package/src/writer.ts +225 -0
package/src/expectations.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isDeepStrictEqual } from "node:util";
|
|
2
2
|
import type { Approver } from "./define.ts";
|
|
3
|
+
import type { ApplyReport } from "./apply.ts";
|
|
3
4
|
import type { DriftReport, DriftResult } from "./drift.ts";
|
|
4
5
|
import type { LedgerRecord } from "./ledger.ts";
|
|
5
6
|
import type { RecordedDecision } from "./policy.ts";
|
|
@@ -82,9 +83,13 @@ const words = (d: RecordedDecision) =>
|
|
|
82
83
|
/** What the policy decided for the calls, each decision once: `allowed and held for finance`. */
|
|
83
84
|
const decisions = (calls: CallRecord[]) => [...new Set(calls.map((r) => words(r.decision)))].join(" and ");
|
|
84
85
|
|
|
85
|
-
/** True when `who` names the approver: a person by id, or a group by its name. */
|
|
86
|
-
const isApprover = (approver: Approver, who:
|
|
87
|
-
typeof
|
|
86
|
+
/** True when `who` names the approver: a person by id, or a group by its name; `{ group }` the group alone. */
|
|
87
|
+
const isApprover = (approver: Approver, who: Approver) =>
|
|
88
|
+
typeof who !== "string"
|
|
89
|
+
? typeof approver !== "string" && approver.group === who.group
|
|
90
|
+
: typeof approver === "string"
|
|
91
|
+
? approver === who
|
|
92
|
+
: approver.group === who;
|
|
88
93
|
|
|
89
94
|
/** How a drift report found a resource, as words: `clean`, `gone`, `drifted in has_wiki, topics`. */
|
|
90
95
|
const found = (r: DriftResult) =>
|
|
@@ -99,7 +104,7 @@ type Unstepped<T> = T extends unknown ? Omit<T, "step"> : never;
|
|
|
99
104
|
* output, or undefined when the report found the resource as expected.
|
|
100
105
|
*/
|
|
101
106
|
function driftDetail(
|
|
102
|
-
e: Extract<CoreExpectation, {
|
|
107
|
+
e: Extract<CoreExpectation, { status: string }>,
|
|
103
108
|
end: Extract<LedgerRecord, { type: "run.finished" }>,
|
|
104
109
|
): string | undefined {
|
|
105
110
|
const report = end.output as Partial<DriftReport> | null | undefined;
|
|
@@ -115,14 +120,38 @@ function driftDetail(
|
|
|
115
120
|
return `${e.declared} is ${found(resource)}, not ${e.status === "drifted" ? `drifted in ${e.field}` : e.status}`;
|
|
116
121
|
}
|
|
117
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Why an apply expectation fails against the report a finished apply run returned as its output:
|
|
125
|
+
* the resource `created` (a step that created it, done), or `bound` to an object in the lockfile it ended with.
|
|
126
|
+
*/
|
|
127
|
+
function applyDetail(
|
|
128
|
+
e: Extract<CoreExpectation, { created: true } | { bound: string }>,
|
|
129
|
+
end: Extract<LedgerRecord, { type: "run.finished" }>,
|
|
130
|
+
): string | undefined {
|
|
131
|
+
const report = end.output as Partial<ApplyReport> | null | undefined;
|
|
132
|
+
if (!Array.isArray(report?.results) || !report.lockfile) return "the run's output is not an apply report";
|
|
133
|
+
if ("bound" in e) {
|
|
134
|
+
const binding = report.lockfile.bindings[e.declared];
|
|
135
|
+
if (binding?.id === e.bound) return undefined;
|
|
136
|
+
return binding ? `${e.declared} is bound to ${binding.id}, not ${e.bound}` : `${e.declared} is not bound`;
|
|
137
|
+
}
|
|
138
|
+
const result = report.results.find((r) => r.id === e.declared);
|
|
139
|
+
// A gone object recreated is bound again; one forgotten is not.
|
|
140
|
+
const created =
|
|
141
|
+
result?.status === "done" && (result.action === "create" || (result.action === "gone" && !!result.binding));
|
|
142
|
+
if (created) return undefined;
|
|
143
|
+
if (!result) return `apply did nothing with ${e.declared}`;
|
|
144
|
+
return `apply's ${result.action} of ${e.declared} ${result.status === "failed" ? `failed: ${result.error}` : `was ${result.status}`}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
118
147
|
/**
|
|
119
148
|
* Why an expectation of the generic steps does not hold against a run's ledger records, or
|
|
120
149
|
* undefined when it does. A call is an `op.called` record without an `error`; one with an error
|
|
121
150
|
* (a policy denial, a rejected approval, a vendor's final failure) was attempted, not made. An
|
|
122
|
-
* operation `was denied` when the policy's decision on some call to it is `deny
|
|
123
|
-
*
|
|
124
|
-
* call, or one that `covers` it. A drift expectation reads the report a finished
|
|
125
|
-
* returned. A step named in the workflow's outline is read by the `name` its records carry
|
|
151
|
+
* operation `was denied` when the policy's decision on some call to it is `deny` (for the `reason`
|
|
152
|
+
* expected, when there is one), and `was held for` an approver (a group alone, as `{ group }`) when
|
|
153
|
+
* an approval for that approver holds it: one the policy asked for on its call, or one that `covers` it. A drift or apply expectation reads the report a finished run
|
|
154
|
+
* of it returned. A step named in the workflow's outline is read by the `name` its records carry
|
|
126
155
|
* (its calls made, and `waitsOf`); a record written before steps had names has none.
|
|
127
156
|
*/
|
|
128
157
|
export function detailOf(e: CoreExpectation, records: readonly LedgerRecord[]): string | undefined {
|
|
@@ -133,13 +162,18 @@ export function detailOf(e: CoreExpectation, records: readonly LedgerRecord[]):
|
|
|
133
162
|
if ("decision" in e) {
|
|
134
163
|
const tries = callsTo(e.op);
|
|
135
164
|
if (e.decision === "denied") {
|
|
136
|
-
|
|
165
|
+
const denials = tries.flatMap(({ decision: d }) => (d.kind === "deny" ? [d.reason] : []));
|
|
166
|
+
if (denials.some((reason) => e.reason === undefined || reason === e.reason)) return undefined;
|
|
167
|
+
if (denials.length) {
|
|
168
|
+
const said = [...new Set(denials)].map((reason) => JSON.stringify(reason)).join(" and ");
|
|
169
|
+
return `${e.op} was denied with ${said}, not ${JSON.stringify(e.reason)}`;
|
|
170
|
+
}
|
|
137
171
|
return tries.length ? `${e.op} was ${decisions(tries)}, not denied` : `no call to ${e.op}`;
|
|
138
172
|
}
|
|
139
173
|
const holds = records.filter(isRequest).filter((r) => r.op === e.op || r.covers.includes(e.op));
|
|
140
174
|
if (holds.some((r) => isApprover(r.approver, e.approver))) return undefined;
|
|
141
175
|
return holds.length
|
|
142
|
-
? `${e.op} was held for ${[...new Set(holds.map((r) => approverLabel(r.approver)))].join(" and ")}, not ${e.approver}`
|
|
176
|
+
? `${e.op} was held for ${[...new Set(holds.map((r) => approverLabel(r.approver)))].join(" and ")}, not ${approverLabel(e.approver)}`
|
|
143
177
|
: tries.length
|
|
144
178
|
? `${e.op} was ${decisions(tries)}, not held`
|
|
145
179
|
: `no call to ${e.op}`;
|
|
@@ -161,5 +195,6 @@ export function detailOf(e: CoreExpectation, records: readonly LedgerRecord[]):
|
|
|
161
195
|
}
|
|
162
196
|
// A finished run: what `the run succeeds` expects, and what a drift expectation reads its report from.
|
|
163
197
|
if (end.type === "run.failed") return `the run failed: ${end.error.message}`;
|
|
164
|
-
|
|
198
|
+
if (!("declared" in e)) return undefined;
|
|
199
|
+
return "status" in e ? driftDetail(e, end) : applyDetail(e, end);
|
|
165
200
|
}
|
package/src/host.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
|
|
3
|
+
// The `Host` contract: what a resource type that runs the company (a container, a Render service,
|
|
4
|
+
// a Fly app) declares about itself, so hosts are swappable and one conformance kit decides
|
|
5
|
+
// whether one works. A host is a resource type like any other; this is what sets it apart.
|
|
6
|
+
|
|
7
|
+
/** How a host keeps the runtime up: `long-lived`, or (later) woken on a schedule. */
|
|
8
|
+
export type HostLifetime = "long-lived" | "wake-on-schedule";
|
|
9
|
+
|
|
10
|
+
/** What a host can do, for choosing one. */
|
|
11
|
+
export interface HostCapabilities {
|
|
12
|
+
lifetime: HostLifetime;
|
|
13
|
+
/** Its `url` is reachable from the internet, not only from this machine. */
|
|
14
|
+
publicUrl: boolean;
|
|
15
|
+
/** It builds the repository from git itself, at the revision apply gives it. */
|
|
16
|
+
buildsFromGit: boolean;
|
|
17
|
+
/** Its outbound connections come from fixed addresses, for a vendor's allow-list. */
|
|
18
|
+
staticEgress: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** What a host's state says of it: where the app answers, which commit it runs, and whether it is up. */
|
|
22
|
+
export interface HostOutputs {
|
|
23
|
+
url: string;
|
|
24
|
+
/** The commit it runs, when its state says. */
|
|
25
|
+
revision?: string;
|
|
26
|
+
healthy: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A resource type's `host`: the fields it takes the runtime's inputs in, by dotted path into its
|
|
31
|
+
* declared fields, and how its state reads as `HostOutputs`. A host is drifted when
|
|
32
|
+
* `outputs(state).revision` differs from what `sanoma.source.resolve` says `ref` is now.
|
|
33
|
+
*/
|
|
34
|
+
export interface HostSpec {
|
|
35
|
+
capabilities: HostCapabilities;
|
|
36
|
+
inputs: {
|
|
37
|
+
/** The repository's clone URL, or a reference to a declared repository whose state holds one (a `by` field). */
|
|
38
|
+
source: string;
|
|
39
|
+
/** The branch (or tag) it runs: `main`, never a commit. */
|
|
40
|
+
ref: string;
|
|
41
|
+
/** The secrets it passes the runtime (`SANOMA_DATABASE_URL`, `SANOMA_SECRETS_KEY`): a write-only field. */
|
|
42
|
+
secrets: string;
|
|
43
|
+
/** The port the app listens on. */
|
|
44
|
+
port: string;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* The field apply fills with the commit `ref` resolves to, before it plans: hidden, as a data
|
|
48
|
+
* file may not declare it, so a merge to `ref` is a change to the host.
|
|
49
|
+
*/
|
|
50
|
+
revision: string;
|
|
51
|
+
outputs: (state: Record<string, unknown>) => HostOutputs;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const LIFETIMES: readonly HostLifetime[] = ["long-lived", "wake-on-schedule"];
|
|
55
|
+
|
|
56
|
+
/** True when `schema` has a field at the dotted `path`, looking through optional, nullable and default wrappers. */
|
|
57
|
+
function hasPath(schema: z.ZodType, path: string): boolean {
|
|
58
|
+
let at: z.ZodType | undefined = schema;
|
|
59
|
+
for (const key of path.split(".")) {
|
|
60
|
+
at = unwrap(at);
|
|
61
|
+
const shape = (at as Partial<z.ZodObject>).shape;
|
|
62
|
+
at = shape?.[key];
|
|
63
|
+
if (!at) return false;
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function unwrap(schema: z.ZodType): z.ZodType {
|
|
69
|
+
let at = schema;
|
|
70
|
+
while ("unwrap" in at && typeof at.unwrap === "function") at = at.unwrap() as z.ZodType;
|
|
71
|
+
return at;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Throws what is wrong with a resource type's `host`: an input or `revision` that names no field,
|
|
76
|
+
* `secrets` that is not write-only, a `revision` that is, an unknown lifetime, a type apply cannot
|
|
77
|
+
* change (not `writable`).
|
|
78
|
+
*/
|
|
79
|
+
export function checkHost(
|
|
80
|
+
what: string,
|
|
81
|
+
host: HostSpec,
|
|
82
|
+
type: { schema: z.ZodObject; fields: { writeOnly: readonly string[] }; writable?: boolean },
|
|
83
|
+
): void {
|
|
84
|
+
const problems: string[] = [];
|
|
85
|
+
if (!type.writable) problems.push("it must be writable, so apply can create and update it");
|
|
86
|
+
if (!LIFETIMES.includes(host.capabilities?.lifetime)) {
|
|
87
|
+
problems.push(`capabilities.lifetime is one of ${LIFETIMES.join(", ")}`);
|
|
88
|
+
}
|
|
89
|
+
for (const [input, path] of [...Object.entries(host.inputs ?? {}), ["revision", host.revision] as const]) {
|
|
90
|
+
if (typeof path !== "string" || !hasPath(type.schema, path)) problems.push(`${input} names no field (${path})`);
|
|
91
|
+
}
|
|
92
|
+
if (!type.fields.writeOnly.includes(host.inputs?.secrets)) problems.push("inputs.secrets must be a write-only field");
|
|
93
|
+
if (type.fields.writeOnly.includes(host.revision)) problems.push("revision must not be write-only: drift reads it");
|
|
94
|
+
if (typeof host.outputs !== "function") problems.push("outputs must be a function of the state");
|
|
95
|
+
if (problems.length) throw new Error(`${what}: its host: ${problems.join("; ")}`);
|
|
96
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -38,14 +38,30 @@ export {
|
|
|
38
38
|
defineResource,
|
|
39
39
|
diffDeclared,
|
|
40
40
|
type FieldReference,
|
|
41
|
+
type PlanAction,
|
|
41
42
|
type References,
|
|
42
43
|
type Resource,
|
|
43
44
|
type ResourceFields,
|
|
44
45
|
type ResourceOps,
|
|
45
46
|
type ResourceSpec,
|
|
47
|
+
withoutWriteOnly,
|
|
46
48
|
} from "./resource.ts";
|
|
47
49
|
export { defineConfig, resolveConfig, resolveDatabaseUrl, type ResolvedConfig, type SanomaConfig } from "./config.ts";
|
|
50
|
+
// `sanoma` itself (the people types' constructors) is at `@sanoma-ai/workflows/resources`, for data files.
|
|
51
|
+
// `collection()` itself is at `@sanoma-ai/workflows/data`, for `resources/data.ts`; this tells one apart, as the launcher does.
|
|
52
|
+
export { isCollection } from "./collection.ts";
|
|
48
53
|
export { type CredentialStatus, credentialsOf } from "./env.ts";
|
|
54
|
+
export {
|
|
55
|
+
APPLY_WORKFLOW,
|
|
56
|
+
ApplyInput,
|
|
57
|
+
type ApplyPlan,
|
|
58
|
+
type ApplyReport,
|
|
59
|
+
type ApplyResult,
|
|
60
|
+
type PlanAnswer,
|
|
61
|
+
type PlanStep,
|
|
62
|
+
} from "./apply.ts";
|
|
63
|
+
export { CALL_WORKFLOW, CallInput } from "./call-workflow.ts";
|
|
64
|
+
export { isDirect } from "./direct.ts";
|
|
49
65
|
export {
|
|
50
66
|
DRIFT_WORKFLOW,
|
|
51
67
|
type DriftDeclared,
|
|
@@ -62,20 +78,32 @@ export {
|
|
|
62
78
|
approve,
|
|
63
79
|
approvedFor,
|
|
64
80
|
type Decision,
|
|
65
|
-
definePolicy,
|
|
66
81
|
deny,
|
|
67
82
|
type Policy,
|
|
68
83
|
type PolicyCall,
|
|
69
84
|
type PolicyOp,
|
|
70
85
|
policyOpOf,
|
|
86
|
+
type PreviewAnswer,
|
|
87
|
+
type PreviewRequest,
|
|
71
88
|
type RecordedDecision,
|
|
72
89
|
} from "./policy.ts";
|
|
73
90
|
export {
|
|
91
|
+
cedarPolicy,
|
|
92
|
+
type CedarPolicySource,
|
|
93
|
+
loadPolicies,
|
|
94
|
+
POLICIES_DIR,
|
|
95
|
+
type PolicyProblem,
|
|
96
|
+
readPolicyFiles,
|
|
97
|
+
validatePolicies,
|
|
98
|
+
} from "./cedar.ts";
|
|
99
|
+
export {
|
|
100
|
+
ApprovalPendingError,
|
|
74
101
|
errorCode,
|
|
75
102
|
type ErrorCode,
|
|
76
103
|
type ErrorInfo,
|
|
77
104
|
type InputIssue,
|
|
78
105
|
invalidInput,
|
|
106
|
+
NotAdmittedError,
|
|
79
107
|
PolicyDeniedError,
|
|
80
108
|
RejectedError,
|
|
81
109
|
SanomaError,
|
|
@@ -93,12 +121,38 @@ export { ApprovalMessage, APPROVALS_EVENT, decisionEventOf } from "./approvals.t
|
|
|
93
121
|
export { RUNTIME_VERSION } from "./version.ts";
|
|
94
122
|
export type { RunArgs } from "./run.ts";
|
|
95
123
|
export { startWorker, type Worker, type WorkerOptions } from "./worker.ts";
|
|
96
|
-
|
|
124
|
+
// `secret` itself is at `@sanoma-ai/workflows/resources`, for data files; this is the drivers' side.
|
|
125
|
+
export {
|
|
126
|
+
generateSecret,
|
|
127
|
+
isSecret,
|
|
128
|
+
memorySecrets,
|
|
129
|
+
type Resolved,
|
|
130
|
+
resolveSecrets,
|
|
131
|
+
type Secret,
|
|
132
|
+
type Secrets,
|
|
133
|
+
} from "./secret.ts";
|
|
134
|
+
export { resolveRevision, SOURCE_RESOLVE, type Source } from "./source.ts";
|
|
135
|
+
export type { HostCapabilities, HostLifetime, HostOutputs, HostSpec } from "./host.ts";
|
|
136
|
+
export {
|
|
137
|
+
type CallOptions,
|
|
138
|
+
SanomaClient,
|
|
139
|
+
type RunsFilter,
|
|
140
|
+
type RunSummary,
|
|
141
|
+
type StartOptions,
|
|
142
|
+
type StoredSecret,
|
|
143
|
+
} from "./client.ts";
|
|
97
144
|
// A raw DBOS client on the runtime's database, for tests and tools that read DBOS directly.
|
|
98
145
|
export { dbosClient } from "./db.ts";
|
|
99
146
|
// Also at `@sanoma-ai/workflows/shared`, which a browser bundle can import.
|
|
100
147
|
export {
|
|
148
|
+
AGENTS,
|
|
101
149
|
approverLabel,
|
|
150
|
+
approverOf,
|
|
151
|
+
declaresPeople,
|
|
152
|
+
type PeopleResource,
|
|
153
|
+
personOf,
|
|
154
|
+
principalOf,
|
|
155
|
+
RUNTIME_AGENTS,
|
|
102
156
|
credentialReady,
|
|
103
157
|
DEFAULT_SLEEP_NAME,
|
|
104
158
|
ENDED_STATUSES,
|
package/src/ledger.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { appendFile, mkdir, readFile, truncate } from "node:fs/promises";
|
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { setTimeout as delay } from "node:timers/promises";
|
|
5
5
|
import { isDeepStrictEqual } from "node:util";
|
|
6
|
-
import { LEDGER_TABLE as TABLE, lazyPool, pgCode } from "./db.ts";
|
|
6
|
+
import { type Db, LEDGER_TABLE as TABLE, lazyPool, pgCode } from "./db.ts";
|
|
7
7
|
import type { Approver, Principal } from "./define.ts";
|
|
8
8
|
import { type ErrorInfo, isFinal, keepCode } from "./errors.ts";
|
|
9
9
|
import { shown, warn } from "./log.ts";
|
|
@@ -80,13 +80,23 @@ export type LedgerRecord = {
|
|
|
80
80
|
op?: string;
|
|
81
81
|
/** The held call's `seq`, which its `op.called` record has, for a policy request. */
|
|
82
82
|
opSeq?: number;
|
|
83
|
+
/** The policies that held the call, by id, for a policy request whose policy names them. */
|
|
84
|
+
reasons?: string[];
|
|
83
85
|
}
|
|
84
86
|
/** A message the approval ignored: from someone other than the approver, or not a decision. */
|
|
85
87
|
| { type: "approval.refused"; approval: string; by?: string; reason: string }
|
|
86
|
-
| {
|
|
88
|
+
| {
|
|
89
|
+
type: "approval.decided";
|
|
90
|
+
approval: string;
|
|
91
|
+
decision: "approve" | "reject";
|
|
92
|
+
by: string;
|
|
93
|
+
note?: string;
|
|
94
|
+
/** Set when `by` started the run: nobody else could decide (`mayDecide`). */
|
|
95
|
+
alone?: true;
|
|
96
|
+
}
|
|
87
97
|
/**
|
|
88
98
|
* A `ctx.sleep` began; `until` is when it ends, in ms since the epoch: the time asked for, or
|
|
89
|
-
* for a duration the
|
|
99
|
+
* for a duration the record's `at` plus the duration, as the runtime saw it. Written
|
|
90
100
|
* for a time already past too, which waits not at all.
|
|
91
101
|
*/
|
|
92
102
|
| {
|
|
@@ -292,25 +302,13 @@ export function postgresLedger(databaseUrl: string): LedgerStore & { close(): Pr
|
|
|
292
302
|
|
|
293
303
|
return {
|
|
294
304
|
async append(record) {
|
|
295
|
-
const line = lineOf(record);
|
|
296
305
|
const conn = await pool.db();
|
|
297
|
-
|
|
298
|
-
.query(
|
|
299
|
-
`INSERT INTO ${TABLE} (id, run_id, seq, record) VALUES ($1, $2, $3, $4::jsonb) ON CONFLICT (id) DO NOTHING RETURNING n`,
|
|
300
|
-
[record.id, record.runId, record.seq, line],
|
|
301
|
-
)
|
|
302
|
-
.catch((err: unknown) => {
|
|
303
|
-
if (DATA_EXCEPTION.test(pgCode(err))) {
|
|
304
|
-
throw final(`Ledger record ${record.id} can't be stored: ${errorMessage(err)}`, err);
|
|
305
|
-
}
|
|
306
|
-
throw err;
|
|
307
|
-
});
|
|
308
|
-
if (inserted.rows.length) return;
|
|
306
|
+
if (await insertRecord(conn, record)) return;
|
|
309
307
|
// Another write of the id was first: the record it stored stands.
|
|
310
308
|
const { rows } = await conn.query<{ record: LedgerRecord }>(`SELECT record FROM ${TABLE} WHERE id = $1`, [
|
|
311
309
|
record.id,
|
|
312
310
|
]);
|
|
313
|
-
if (rows[0]) warnIfDifferent(rows[0].record,
|
|
311
|
+
if (rows[0]) warnIfDifferent(rows[0].record, lineOf(record));
|
|
314
312
|
},
|
|
315
313
|
async read(runId) {
|
|
316
314
|
const { rows } = await (
|
|
@@ -323,6 +321,26 @@ export function postgresLedger(databaseUrl: string): LedgerStore & { close(): Pr
|
|
|
323
321
|
};
|
|
324
322
|
}
|
|
325
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Stores the record in the Postgres ledger's table through `db`, unless one with its id is
|
|
326
|
+
* stored: true when this one was. What `postgresLedger` appends with, and a direct write
|
|
327
|
+
* (`SanomaClient.call`) inside the transaction that saves its record.
|
|
328
|
+
*/
|
|
329
|
+
export async function insertRecord(db: Db, record: LedgerRecord): Promise<boolean> {
|
|
330
|
+
const inserted = await db
|
|
331
|
+
.query(
|
|
332
|
+
`INSERT INTO ${TABLE} (id, run_id, seq, record) VALUES ($1, $2, $3, $4::jsonb) ON CONFLICT (id) DO NOTHING RETURNING n`,
|
|
333
|
+
[record.id, record.runId, record.seq, lineOf(record)],
|
|
334
|
+
)
|
|
335
|
+
.catch((err: unknown) => {
|
|
336
|
+
if (DATA_EXCEPTION.test(pgCode(err))) {
|
|
337
|
+
throw final(`Ledger record ${record.id} can't be stored: ${errorMessage(err)}`, err);
|
|
338
|
+
}
|
|
339
|
+
throw err;
|
|
340
|
+
});
|
|
341
|
+
return inserted.rows.length > 0;
|
|
342
|
+
}
|
|
343
|
+
|
|
326
344
|
/** Keeps records in memory, for tests. */
|
|
327
345
|
export function memoryLedger(): LedgerStore {
|
|
328
346
|
const runs = new Map<string, Map<string, LedgerRecord>>();
|
package/src/lint.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
1
2
|
import { basename, dirname, join } from "node:path";
|
|
2
3
|
import { byPosition, childrenOf, type LintProblem, type Node, parsed, type SourceFile } from "./ast.ts";
|
|
3
|
-
import { readDataFile } from "./datafile.ts";
|
|
4
|
+
import { COLLECTIONS_FILE, readDataFile } from "./datafile.ts";
|
|
4
5
|
import { constsOf, outlineBody, workflowLiterals } from "./outline.ts";
|
|
5
6
|
import { inside, nearestDir, RELATIVE } from "./paths.ts";
|
|
6
7
|
// From src/ and from dist/ alike, the package's own oxlint.json: the one list of allowed names.
|
|
@@ -33,7 +34,7 @@ const REFUSED_PACKAGES: [RegExp, string][] = [
|
|
|
33
34
|
];
|
|
34
35
|
|
|
35
36
|
/**
|
|
36
|
-
* What a workflow
|
|
37
|
+
* What a workflow may import from an author package, besides types: the one list,
|
|
37
38
|
* `allowImportNames` of oxlint.json's `@sanoma-ai/workflows` entry (`sanoma`'s generated
|
|
38
39
|
* `oxlint.json` derives its own entry from it). Everything else is
|
|
39
40
|
* refused: the client, the worker and the app could start runs or approve the run's own
|
|
@@ -46,7 +47,7 @@ const WORKFLOW_IMPORTS: readonly string[] =
|
|
|
46
47
|
const ALLOWED_NAMES = new Set(WORKFLOW_IMPORTS);
|
|
47
48
|
|
|
48
49
|
const NOT_ALLOWED =
|
|
49
|
-
`a workflow
|
|
50
|
+
`a workflow imports only ${WORKFLOW_IMPORTS.join(", ")} and types from ` +
|
|
50
51
|
`${AUTHOR_PACKAGES.join(" or ")}: the rest could start runs, approve its own approvals, forge the ledger or read credentials`;
|
|
51
52
|
|
|
52
53
|
/**
|
|
@@ -57,11 +58,12 @@ const NOT_ALLOWED =
|
|
|
57
58
|
const ERROR_CLASSES = new Set(["DriverError", "SanomaError", "PolicyDeniedError", "RejectedError"]);
|
|
58
59
|
|
|
59
60
|
/**
|
|
60
|
-
* Checks a workflow
|
|
61
|
+
* Checks a workflow file. Imports allowed: the names in `WORKFLOW_IMPORTS` and any
|
|
61
62
|
* type from `@sanoma-ai/workflows` or `sanoma`, `@sanoma-ai/connector-<vendor>`, `zod`, and relative files. With
|
|
62
|
-
* `filename`, a relative import must stay inside the file's nearest `workflows/` or
|
|
63
|
-
*
|
|
64
|
-
*
|
|
63
|
+
* `filename`, a relative import must stay inside the file's nearest `workflows/` directory, or its
|
|
64
|
+
* own directory when it has none; the one file outside it a workflow may import is the company's
|
|
65
|
+
* `resources/data.ts`, its collections (by real path). No
|
|
66
|
+
* `instanceof` against the runtime's error classes: read `errorCode(err)`.
|
|
65
67
|
*
|
|
66
68
|
* In each workflow's `run` (the literal `defineWorkflow` is called with), it reports what the
|
|
67
69
|
* outline cannot draw, as the worker refuses to start with (`outlineBody`): `ctx` used other
|
|
@@ -82,7 +84,7 @@ export function lintWorkflow(input: string | SourceFile, path?: string): LintPro
|
|
|
82
84
|
const read = typeof input === "string" ? parsed(input, filename ?? "workflow.ts") : input;
|
|
83
85
|
const { program, at } = read;
|
|
84
86
|
const problems = [...read.problems];
|
|
85
|
-
const root = filename === undefined ? undefined : nearestDir(filename, ["workflows"
|
|
87
|
+
const root = filename === undefined ? undefined : nearestDir(filename, ["workflows"]);
|
|
86
88
|
|
|
87
89
|
const checkSource = (node: Node) => {
|
|
88
90
|
const spec: string = node.source.value;
|
|
@@ -197,12 +199,27 @@ type RestrictedPath = { name: string; allowImportNames?: string[] };
|
|
|
197
199
|
|
|
198
200
|
function whyRefused(spec: string, filename: string | undefined, root: string | undefined): string | undefined {
|
|
199
201
|
if (RELATIVE.test(spec)) {
|
|
200
|
-
if (filename === undefined || root === undefined
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
202
|
+
if (filename === undefined || root === undefined) return undefined;
|
|
203
|
+
const target = join(dirname(filename), spec);
|
|
204
|
+
if (inside(root, target)) return undefined;
|
|
205
|
+
const tree = basename(root) === "workflows";
|
|
206
|
+
// The collections, which a workflow names in `uses`: the one data file the runtime imports too.
|
|
207
|
+
if (tree && realPath(target) === realPath(join(dirname(root), "resources", COLLECTIONS_FILE))) return undefined;
|
|
208
|
+
return (
|
|
209
|
+
`it reaches outside ${tree ? `${basename(root)}/` : "this directory"}, toward the config and its drivers; ${THROUGH_CTX}` +
|
|
210
|
+
(tree ? "; of the files outside it, only resources/data.ts (the collections) may be imported" : "")
|
|
211
|
+
);
|
|
204
212
|
}
|
|
205
213
|
for (const [pattern, reason] of REFUSED_PACKAGES) if (pattern.test(spec)) return reason;
|
|
206
214
|
if (ALLOWED_PACKAGE.test(spec)) return undefined;
|
|
207
215
|
return "import only from `sanoma` (or `@sanoma-ai/workflows`), a `@sanoma-ai/connector-*` package, zod, or a file beside this one";
|
|
208
216
|
}
|
|
217
|
+
|
|
218
|
+
/** A path's real path, or the path itself when it does not exist. */
|
|
219
|
+
function realPath(path: string): string {
|
|
220
|
+
try {
|
|
221
|
+
return realpathSync(path);
|
|
222
|
+
} catch {
|
|
223
|
+
return path;
|
|
224
|
+
}
|
|
225
|
+
}
|
package/src/lockfile.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import type { ResourceProblem } from "./resources.ts";
|
|
5
|
+
|
|
6
|
+
// The lockfile: which vendor object each declaration means, kept apart from the data files (which
|
|
7
|
+
// hold intent only), as `package-lock.json` is from `package.json`. Only apply writes it.
|
|
8
|
+
|
|
9
|
+
/** Where the lockfile is, relative to the config's root. */
|
|
10
|
+
export const LOCKFILE = "resources/sanoma.lock.json";
|
|
11
|
+
|
|
12
|
+
/** Which vendor object a declaration means: its `<vendor>.<type>` and the vendor's stable id for it. */
|
|
13
|
+
export interface Binding {
|
|
14
|
+
type: string;
|
|
15
|
+
id: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The lockfile's content: by declaration key, `<file>#<export>` (the file relative to the config's root, with `/`). */
|
|
19
|
+
export type Bindings = Record<string, Binding>;
|
|
20
|
+
|
|
21
|
+
const lockfile = z.record(
|
|
22
|
+
z.string().regex(/^resources\/[^#]+#[^#]+$/),
|
|
23
|
+
z.strictObject({ type: z.string().regex(/^[^.\s]+\.[^.\s]+$/), id: z.string().min(1) }),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The lockfile under `root`: no bindings when there is none, and no bindings with a problem when
|
|
28
|
+
* it is not JSON or not a map of `<file>#<export>` to `{ type, id }`.
|
|
29
|
+
*/
|
|
30
|
+
export function readLockfile(root: string): { bindings: Bindings; problems: ResourceProblem[] } {
|
|
31
|
+
const path = join(root, LOCKFILE);
|
|
32
|
+
if (!existsSync(path)) return { bindings: {}, problems: [] };
|
|
33
|
+
let json: unknown;
|
|
34
|
+
try {
|
|
35
|
+
json = JSON.parse(readFileSync(path, "utf8"));
|
|
36
|
+
} catch (e) {
|
|
37
|
+
return { bindings: {}, problems: [{ file: LOCKFILE, message: `not JSON: ${(e as Error).message}` }] };
|
|
38
|
+
}
|
|
39
|
+
const parsed = lockfile.safeParse(json);
|
|
40
|
+
if (!parsed.success) {
|
|
41
|
+
// One line: zod's own spans lines.
|
|
42
|
+
return {
|
|
43
|
+
bindings: {},
|
|
44
|
+
problems: [{ file: LOCKFILE, message: z.prettifyError(parsed.error).replaceAll(/\s*\n\s*/g, " ") }],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return { bindings: parsed.data, problems: [] };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The lockfile's text for `bindings`: keys sorted, two-space JSON, a trailing newline, so a diff shows only what changed. */
|
|
51
|
+
export function serializeLockfile(bindings: Bindings): string {
|
|
52
|
+
const sorted = Object.entries(bindings)
|
|
53
|
+
.toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
54
|
+
.map(([key, { type, id }]) => [key, { type, id }]);
|
|
55
|
+
return `${JSON.stringify(Object.fromEntries(sorted), null, 2)}\n`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Writes the lockfile under `root` whole or not at all: to a file beside it, then renamed over it. Apply only. */
|
|
59
|
+
export function writeLockfile(root: string, bindings: Bindings): void {
|
|
60
|
+
const path = join(root, LOCKFILE);
|
|
61
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
62
|
+
try {
|
|
63
|
+
writeFileSync(temporary, serializeLockfile(bindings));
|
|
64
|
+
renameSync(temporary, path);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
rmSync(temporary, { force: true });
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One change to the lockfile: a declaration bound for the first time (`bind`, `to`), pointed at
|
|
73
|
+
* another object (`rebind`, `from` and `to`), or no longer bound (`unbind`, `from`). `type` is
|
|
74
|
+
* the binding's `<vendor>.<type>`, the new one's for a rebind.
|
|
75
|
+
*/
|
|
76
|
+
export interface Rebind {
|
|
77
|
+
key: string;
|
|
78
|
+
type: string;
|
|
79
|
+
kind: "bind" | "rebind" | "unbind";
|
|
80
|
+
from?: string;
|
|
81
|
+
to?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** What changed from `before` to `after`, by key, sorted. */
|
|
85
|
+
export function diffLockfiles(before: Bindings, after: Bindings): Rebind[] {
|
|
86
|
+
const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])].toSorted();
|
|
87
|
+
return keys.flatMap((key): Rebind[] => {
|
|
88
|
+
const was = before[key];
|
|
89
|
+
const now = after[key];
|
|
90
|
+
if (!now) return was ? [{ key, type: was.type, kind: "unbind", from: was.id }] : [];
|
|
91
|
+
if (!was) return [{ key, type: now.type, kind: "bind", to: now.id }];
|
|
92
|
+
if (was.id === now.id && was.type === now.type) return [];
|
|
93
|
+
return [{ key, type: now.type, kind: "rebind", from: was.id, to: now.id }];
|
|
94
|
+
});
|
|
95
|
+
}
|
package/src/op.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { z } from "zod";
|
|
2
2
|
import type { Fake } from "./fake.ts";
|
|
3
|
+
import type { Secrets } from "./secret.ts";
|
|
3
4
|
import type { Steps } from "./steps.ts";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -235,6 +236,14 @@ export interface CallContext {
|
|
|
235
236
|
opId: string;
|
|
236
237
|
/** 1 on the first try; retries count up (idempotent operations only). */
|
|
237
238
|
attempt: number;
|
|
239
|
+
/** The secrets store, for the values `secret()` markers name: `resolveSecrets(input, call.secrets)`. In memory for a fake. */
|
|
240
|
+
secrets: Secrets;
|
|
241
|
+
/**
|
|
242
|
+
* Who the call is made as: `run:<runId>` for a call in a run; the person's id for a direct call
|
|
243
|
+
* (`SanomaClient.call`) and in a run of the built-in `call` workflow. A collection's save
|
|
244
|
+
* records it as the record's `updatedBy`.
|
|
245
|
+
*/
|
|
246
|
+
by?: string;
|
|
238
247
|
}
|
|
239
248
|
|
|
240
249
|
export type DriverFn<I = any, O = unknown> = (input: I, call: CallContext) => Promise<O>;
|
package/src/outline.ts
CHANGED
|
@@ -576,7 +576,7 @@ function ctxPath(callee: Node, ctx: string): string[] | undefined {
|
|
|
576
576
|
}
|
|
577
577
|
|
|
578
578
|
/** A string literal's value, or a template literal's with nothing interpolated. */
|
|
579
|
-
function stringValue(node: Node): string | undefined {
|
|
579
|
+
export function stringValue(node: Node): string | undefined {
|
|
580
580
|
const n = unwrap(node);
|
|
581
581
|
if (n?.type === "Literal" && typeof n.value === "string") return n.value;
|
|
582
582
|
if (n?.type === "TemplateLiteral" && n.expressions.length === 0) return n.quasis[0]?.value.cooked ?? undefined;
|
package/src/paths.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
2
3
|
|
|
3
|
-
// Where an import may reach, as the lints and the data-file reader all decide it,
|
|
4
|
-
//
|
|
4
|
+
// Where an import may reach, as the lints and the data-file reader all decide it, the one walk up
|
|
5
|
+
// the directories and the one walk down (`filesUnder`). Node's `path` and `fs` only, no parser:
|
|
6
|
+
// the oxlint plugin loads it.
|
|
5
7
|
|
|
6
8
|
/** A relative import specifier: `./x.ts`, `../y.ts`. */
|
|
7
9
|
export const RELATIVE = /^\.\.?\//;
|
|
@@ -30,3 +32,16 @@ export function inside(dir: string, path: string): boolean {
|
|
|
30
32
|
const rel = relative(dir, path);
|
|
31
33
|
return rel === "" || (rel.split(sep)[0] !== ".." && !isAbsolute(rel));
|
|
32
34
|
}
|
|
35
|
+
|
|
36
|
+
/** True when `path` is a directory. */
|
|
37
|
+
export const isDirectory = (path: string): boolean => statSync(path, { throwIfNoEntry: false })?.isDirectory() ?? false;
|
|
38
|
+
|
|
39
|
+
/** The files under `dir` whose name `keep` takes, sorted, outside `node_modules`; none when `dir` is not a directory. */
|
|
40
|
+
export function filesUnder(dir: string, keep: (name: string) => boolean): string[] {
|
|
41
|
+
if (!isDirectory(dir)) return [];
|
|
42
|
+
return readdirSync(dir, { recursive: true, withFileTypes: true })
|
|
43
|
+
.filter((d) => d.isFile() && keep(d.name))
|
|
44
|
+
.map((d) => join(d.parentPath, d.name))
|
|
45
|
+
.filter((path) => !relative(dir, path).split(sep).includes("node_modules"))
|
|
46
|
+
.toSorted();
|
|
47
|
+
}
|