@offsida/cli 0.2.0 → 0.3.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/dist/api.d.ts +5 -2
- package/dist/api.js +28 -11
- package/dist/auth.d.ts +1 -0
- package/dist/auth.js +13 -2
- package/dist/bundle.d.ts +12 -0
- package/dist/bundle.js +54 -22
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +52 -21
- package/dist/prepare.d.ts +11 -0
- package/dist/prepare.js +65 -0
- package/dist/push-directory.d.ts +2 -2
- package/dist/push-directory.js +22 -13
- package/dist/push.d.ts +1 -0
- package/dist/push.js +7 -1
- package/dist/resolve-package.d.ts +13 -5
- package/dist/resolve-package.js +58 -39
- package/dist/workflows.js +74 -8
- package/package.json +2 -2
package/dist/api.d.ts
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
import { type AuthOpts } from './auth.js';
|
|
2
|
+
type Sleep = (ms: number) => Promise<void>;
|
|
2
3
|
export type Api = {
|
|
3
4
|
url: string;
|
|
4
5
|
org: string;
|
|
5
6
|
headers: Record<string, string>;
|
|
6
7
|
fetch: typeof fetch;
|
|
8
|
+
sleep: Sleep;
|
|
7
9
|
};
|
|
8
10
|
export type Res = {
|
|
9
11
|
status: number;
|
|
10
12
|
body: unknown;
|
|
11
13
|
};
|
|
12
|
-
export declare function connect(opts: AuthOpts, fetchImpl?: typeof fetch): Promise<Api>;
|
|
14
|
+
export declare function connect(opts: AuthOpts, fetchImpl?: typeof fetch, sleep?: Sleep): Promise<Api>;
|
|
13
15
|
export declare function call(api: Api, method: string, path: string, init?: {
|
|
14
16
|
body?: unknown;
|
|
15
17
|
headers?: Record<string, string>;
|
|
16
18
|
}): Promise<Res>;
|
|
17
19
|
export declare function errorCode(res: Res): string | undefined;
|
|
18
|
-
export declare function failure(operation: string, res: Res): Error;
|
|
20
|
+
export declare function failure(operation: string, res: Res, scope?: string): Error;
|
|
19
21
|
export declare function isRetryable(res: Res): boolean;
|
|
20
22
|
export declare function withRetry(send: () => Promise<Res>, sleep: (ms: number) => Promise<void>): Promise<Res>;
|
|
23
|
+
export {};
|
package/dist/api.js
CHANGED
|
@@ -1,16 +1,33 @@
|
|
|
1
1
|
import { ORG_HEADER } from '@offsida/types';
|
|
2
|
-
import { assertTarget, authHeaders } from './auth.js';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
const
|
|
6
|
-
|
|
2
|
+
import { assertTarget, authHeaders, reach } from './auth.js';
|
|
3
|
+
const realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
4
|
+
export async function connect(opts, fetchImpl = fetch, sleep = realSleep) {
|
|
5
|
+
const url = opts.url.replace(/\/+$/, '');
|
|
6
|
+
assertTarget({ ...opts, url });
|
|
7
|
+
const auth = await authHeaders({ ...opts, url }, fetchImpl);
|
|
8
|
+
return { url, org: opts.org, headers: { ...auth, [ORG_HEADER]: opts.org }, fetch: fetchImpl, sleep };
|
|
7
9
|
}
|
|
10
|
+
const BACKOFF_MS = [500, 1500];
|
|
11
|
+
const MAX_RETRY_AFTER_MS = 60_000;
|
|
12
|
+
// A 429 is refused before the route runs (token-auth throttle, per-route limits), so resending is safe
|
|
13
|
+
// for every method. Waits the server's Retry-After (capped), else the backoff; three attempts in all.
|
|
8
14
|
// Never throws on status: callers branch on it. A non-JSON body (a proxy's HTML 502) reads as undefined.
|
|
9
15
|
export async function call(api, method, path, init) {
|
|
16
|
+
let res = await send(api, method, path, init);
|
|
17
|
+
for (const backoff of BACKOFF_MS) {
|
|
18
|
+
if (res.status !== 429)
|
|
19
|
+
break;
|
|
20
|
+
const after = Number(res.retryAfter);
|
|
21
|
+
await api.sleep(res.retryAfter && Number.isFinite(after) ? Math.min(after * 1000, MAX_RETRY_AFTER_MS) : backoff);
|
|
22
|
+
res = await send(api, method, path, init);
|
|
23
|
+
}
|
|
24
|
+
return { status: res.status, body: res.body };
|
|
25
|
+
}
|
|
26
|
+
async function send(api, method, path, init) {
|
|
10
27
|
const headers = { ...api.headers, ...init?.headers };
|
|
11
28
|
if (init?.body !== undefined)
|
|
12
29
|
headers['content-type'] = 'application/json';
|
|
13
|
-
const res = await api.fetch
|
|
30
|
+
const res = await reach(api.fetch, `${api.url}/api${path}`, {
|
|
14
31
|
method,
|
|
15
32
|
headers,
|
|
16
33
|
body: init?.body === undefined ? undefined : JSON.stringify(init.body),
|
|
@@ -23,7 +40,7 @@ export async function call(api, method, path, init) {
|
|
|
23
40
|
catch {
|
|
24
41
|
body = undefined;
|
|
25
42
|
}
|
|
26
|
-
return { status: res.status, body };
|
|
43
|
+
return { status: res.status, body, retryAfter: res.headers.get('retry-after') };
|
|
27
44
|
}
|
|
28
45
|
export function errorCode(res) {
|
|
29
46
|
const b = (res.body ?? {});
|
|
@@ -34,11 +51,12 @@ export function errorCode(res) {
|
|
|
34
51
|
}
|
|
35
52
|
// Reads both error envelopes: AppError `{ error: { code, message } }`, and the idempotency/validation
|
|
36
53
|
// `{ error: string, code?, field? }`.
|
|
37
|
-
export function failure(operation, res) {
|
|
54
|
+
export function failure(operation, res, scope) {
|
|
38
55
|
if (res.status === 401)
|
|
39
56
|
return new Error('auth rejected (HTTP 401) — token invalid/revoked, or login expired');
|
|
40
|
-
if (res.status === 403)
|
|
41
|
-
return new Error(`forbidden (HTTP 403) — ${operation} needs a token with the right scope`);
|
|
57
|
+
if (res.status === 403) {
|
|
58
|
+
return new Error(`forbidden (HTTP 403) — ${operation} needs a token with ${scope ? `the ${scope}` : 'the right'} scope`);
|
|
59
|
+
}
|
|
42
60
|
let message = `${operation} failed (HTTP ${res.status})`;
|
|
43
61
|
const b = (res.body ?? {});
|
|
44
62
|
if (b.error && typeof b.error === 'object') {
|
|
@@ -60,7 +78,6 @@ export function isRetryable(res) {
|
|
|
60
78
|
const e = res.body?.error;
|
|
61
79
|
return !!e && typeof e === 'object' && e.retryable === true;
|
|
62
80
|
}
|
|
63
|
-
const BACKOFF_MS = [500, 1500];
|
|
64
81
|
// Up to three attempts. The caller's `send` reuses one Idempotency-Key, so the server deduplicates.
|
|
65
82
|
export async function withRetry(send, sleep) {
|
|
66
83
|
let res = await send();
|
package/dist/auth.d.ts
CHANGED
|
@@ -8,4 +8,5 @@ export type AuthOpts = {
|
|
|
8
8
|
};
|
|
9
9
|
export declare function isLocal(url: string): boolean;
|
|
10
10
|
export declare function assertTarget(opts: AuthOpts): void;
|
|
11
|
+
export declare function reach(fetchImpl: typeof fetch, url: string, init: RequestInit): Promise<Response>;
|
|
11
12
|
export declare function authHeaders(opts: AuthOpts, fetchImpl?: typeof fetch): Promise<Record<string, string>>;
|
package/dist/auth.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export function isLocal(url) {
|
|
2
2
|
try {
|
|
3
3
|
const h = new URL(url).hostname;
|
|
4
|
-
return h === 'localhost' || h === '127.0.0.1' || h === '0.0.0.0';
|
|
4
|
+
return h === 'localhost' || h === '127.0.0.1' || h === '0.0.0.0' || h === '[::1]';
|
|
5
5
|
}
|
|
6
6
|
catch {
|
|
7
7
|
return false;
|
|
@@ -19,13 +19,24 @@ export function assertTarget(opts) {
|
|
|
19
19
|
throw new Error(`refusing to push to non-local URL ${opts.url} without --allow-remote (or OFFSIDA_ALLOW_REMOTE=1)`);
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
|
+
// fetch rejects with a bare "fetch failed"; name the URL and the socket error (ECONNREFUSED, ENOTFOUND…).
|
|
23
|
+
export async function reach(fetchImpl, url, init) {
|
|
24
|
+
try {
|
|
25
|
+
return await fetchImpl(url, init);
|
|
26
|
+
}
|
|
27
|
+
catch (e) {
|
|
28
|
+
const cause = e.cause;
|
|
29
|
+
const detail = typeof cause?.code === 'string' ? cause.code : e.message;
|
|
30
|
+
throw new Error(`cannot reach ${url} (${detail})`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
22
33
|
// Two auth modes:
|
|
23
34
|
// • token — non-interactive (CI): `Authorization: Bearer <osk_…>`, an org API token (Story 022).
|
|
24
35
|
// • email+code — interactive: exchange an emailed login code for a session cookie.
|
|
25
36
|
export async function authHeaders(opts, fetchImpl = fetch) {
|
|
26
37
|
if (opts.token)
|
|
27
38
|
return { authorization: `Bearer ${opts.token}` };
|
|
28
|
-
const verify = await fetchImpl
|
|
39
|
+
const verify = await reach(fetchImpl, `${opts.url}/api/auth/verify`, {
|
|
29
40
|
method: 'POST',
|
|
30
41
|
headers: { 'content-type': 'application/json' },
|
|
31
42
|
body: JSON.stringify({ email: opts.email, code: opts.code }),
|
package/dist/bundle.d.ts
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
|
+
import type { ArtifactFile } from './resolve-package.js';
|
|
1
2
|
export declare function bundlePackage(dir: string, opts?: {
|
|
2
3
|
alias?: Record<string, string>;
|
|
3
4
|
}): Promise<string>;
|
|
4
5
|
export declare function bundleEntry(entryFile: string, opts?: {
|
|
5
6
|
alias?: Record<string, string>;
|
|
6
7
|
}): Promise<string>;
|
|
8
|
+
/**
|
|
9
|
+
* The generated entry for `<name>.action.ts`: its default export, as the one action of a definePackage
|
|
10
|
+
* named after the file. definePackage is what turns Zod schemas into JSON Schema; the guard turns a
|
|
11
|
+
* missing or wrong default export into the publish probe's failure reason instead of a run-time miss.
|
|
12
|
+
*/
|
|
13
|
+
export declare function actionWrapper(dir: string, artifact: ArtifactFile): string;
|
|
14
|
+
export type BundledArtifact = {
|
|
15
|
+
code: string;
|
|
16
|
+
inputs: string[];
|
|
17
|
+
};
|
|
18
|
+
export declare function bundleArtifact(dir: string, artifact: ArtifactFile): Promise<BundledArtifact>;
|
package/dist/bundle.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as esbuild from 'esbuild-wasm';
|
|
2
|
-
import { join } from 'node:path';
|
|
2
|
+
import { join, relative, resolve, sep } from 'node:path';
|
|
3
3
|
let initialized = false;
|
|
4
4
|
async function ensureInit() {
|
|
5
5
|
if (initialized)
|
|
@@ -7,33 +7,65 @@ async function ensureInit() {
|
|
|
7
7
|
await esbuild.initialize({});
|
|
8
8
|
initialized = true;
|
|
9
9
|
}
|
|
10
|
+
const OPTIONS = {
|
|
11
|
+
bundle: true,
|
|
12
|
+
format: 'esm',
|
|
13
|
+
platform: 'neutral',
|
|
14
|
+
mainFields: ['module', 'main'],
|
|
15
|
+
conditions: ['workerd', 'worker', 'browser', 'default'],
|
|
16
|
+
external: ['cloudflare:workers', 'cloudflare:test', 'node:*', 'fs', 'fs/promises', 'path'],
|
|
17
|
+
target: 'es2022',
|
|
18
|
+
write: false,
|
|
19
|
+
legalComments: 'none',
|
|
20
|
+
// Halves an SDK package (most of it is zod) against the platform's 1 MiB cap (push.ts CODE_SIZE_LIMIT).
|
|
21
|
+
minify: true,
|
|
22
|
+
};
|
|
23
|
+
function outputText(result) {
|
|
24
|
+
const out = result.outputFiles?.[0]?.text;
|
|
25
|
+
if (!out)
|
|
26
|
+
throw new Error('esbuild produced no output');
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
10
29
|
// Bundle <dir>/index.ts (and its local imports) into a single ESM module string,
|
|
11
|
-
// preserving the package's `export default {...}`. Used by
|
|
12
|
-
//
|
|
30
|
+
// preserving the package's `export default {...}`. Used by the platform conformance
|
|
31
|
+
// test and `pushPackage`. `opts.alias` lets callers resolve bare import
|
|
13
32
|
// prefixes (e.g. the `@core` workspace alias) — esbuild rewrites subpaths, so
|
|
14
33
|
// `{ '@core': '/abs/lib/core/src' }` maps `@core/services/x` → `/abs/lib/core/src/services/x`.
|
|
15
34
|
export async function bundlePackage(dir, opts) {
|
|
16
35
|
return bundleEntry(join(dir, 'index.ts'), opts);
|
|
17
36
|
}
|
|
18
|
-
// Bundle one entry file (and its local imports).
|
|
19
|
-
// entry comes from package.json or the src/index.ts convention (resolve-package.ts).
|
|
37
|
+
// Bundle one entry file (and its local imports).
|
|
20
38
|
export async function bundleEntry(entryFile, opts) {
|
|
21
39
|
await ensureInit();
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
return outputText(await esbuild.build({ ...OPTIONS, entryPoints: [entryFile], alias: opts?.alias }));
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The generated entry for `<name>.action.ts`: its default export, as the one action of a definePackage
|
|
44
|
+
* named after the file. definePackage is what turns Zod schemas into JSON Schema; the guard turns a
|
|
45
|
+
* missing or wrong default export into the publish probe's failure reason instead of a run-time miss.
|
|
46
|
+
*/
|
|
47
|
+
export function actionWrapper(dir, artifact) {
|
|
48
|
+
const rel = relative(dir, artifact.file).split(sep).join('/');
|
|
49
|
+
return [
|
|
50
|
+
`import { definePackage } from '@offsida/sdk';`,
|
|
51
|
+
`import action from ${JSON.stringify(`./${rel}`)};`,
|
|
52
|
+
`if (!action || typeof action.run !== 'function') {`,
|
|
53
|
+
`\tthrow new Error(${JSON.stringify(`${rel} must default-export defineAction({ … run … })`)});`,
|
|
54
|
+
`}`,
|
|
55
|
+
`export default definePackage({ actions: { ${JSON.stringify(artifact.name)}: action } });`,
|
|
56
|
+
``,
|
|
57
|
+
].join('\n');
|
|
58
|
+
}
|
|
59
|
+
// Bundle one artifact file. `inputs` lists every source file in the bundle (absolute), so a caller can
|
|
60
|
+
// see what a package file pulls in.
|
|
61
|
+
export async function bundleArtifact(dir, artifact) {
|
|
62
|
+
await ensureInit();
|
|
63
|
+
const entry = artifact.kind === 'action'
|
|
64
|
+
? { stdin: { contents: actionWrapper(dir, artifact), resolveDir: dir, sourcefile: `<offsida:${artifact.name}>`, loader: 'ts' } }
|
|
65
|
+
: { entryPoints: [artifact.file] };
|
|
66
|
+
const result = await esbuild.build({ ...OPTIONS, ...entry, absWorkingDir: dir, metafile: true });
|
|
67
|
+
const inputs = Object.keys(result.metafile.inputs)
|
|
68
|
+
.filter((p) => !p.startsWith('<'))
|
|
69
|
+
.map((p) => resolve(dir, p));
|
|
70
|
+
return { code: outputText(result), inputs };
|
|
39
71
|
}
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { type PushDirectoryDeps } from './push-directory.js';
|
|
3
|
-
export declare const USAGE = "usage:\n offsida push [dir] [--publish] [--url <u>] [--org <id>] [--allow-remote]\n offsida build [dir] [--out <
|
|
3
|
+
export declare const USAGE = "usage:\n offsida push [dir] [--publish] [--url <u>] [--org <id>] [--allow-remote]\n offsida build [dir] [--out-dir <dir>]\n\ndir defaults to the current directory and must contain a package.json.\n\nEvery file below dir is one artifact, named after the file:\n <name>.action.ts default-exports defineAction(\u2026); uploaded as a package holding that\n one action, also called <name>. Names are kebab-case (a-z, 0-9, -).\n <name>.package.ts default-exports definePackage(\u2026) \u2014 for connections, triggers, config,\n or several actions sharing one revision. Letters, digits, . _ -.\nNames are per org: two packages pushing the same name overwrite each other's artifact.\nA package file may not import an action file (that action would be published twice).\n\npush uploads every package file, then every action file (so an action requiring a\npackage's connection gets the one just pushed), then every *.workflow.json as that\nworkflow's draft, replacing edits made in the Offsida editor. A failed upload skips\nthe workflows. Subfolders with their own package.json are separate packages and are\nskipped, as are node_modules, dist and dot-folders. A workflow file's optional \"id\"\npins one workflow in one org; without it the workflow is matched by name or created.\n--publish also publishes each workflow; \"latest\" steps lock to the code just pushed.\n\nbuild bundles every artifact without uploading: with --out-dir it writes <name>.js\nfor each; without, it prints each artifact's size against the cap.\n\nA first publish goes live with no capability confirmation (offsida.web#14). A later\npublish that adds access is left for confirming in Offsida (exit 2). Every push mints\na new revision of every artifact, so --publish republishes \"latest\" workflows\n(offsida.web#15).\n\nAuth (environment only): OFFSIDA_TOKEN, or OFFSIDA_EMAIL + OFFSIDA_CODE.\nScopes: code:publish; workflows:list, :get, :create, :update; workflows:publish with --publish.\nAlso: OFFSIDA_URL (default http://localhost:3808), OFFSIDA_ORG, OFFSIDA_ALLOW_REMOTE=1.\n\nLimits: name matching reads a list that lags a few seconds, so two quick pushes of a\nnew workflow can create two. Same-named workflows in different packages overwrite\neach other without \"id\". Deleting a *.workflow.json leaves its workflow live.\n\nBundles are minified and must stay under the platform's 1 MiB cap. A rate-limited\nrequest (429) waits out its Retry-After, up to a minute, and is retried.\n\nExit codes: 0 done, 1 something failed, 2 something needs confirming in Offsida.";
|
|
4
4
|
type RunDeps = PushDirectoryDeps & {
|
|
5
5
|
out?: (s: string) => void;
|
|
6
6
|
err?: (s: string) => void;
|
package/dist/cli.js
CHANGED
|
@@ -1,26 +1,41 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { realpathSync } from 'node:fs';
|
|
3
|
-
import { writeFile } from 'node:fs/promises';
|
|
3
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { join, relative } from 'node:path';
|
|
4
5
|
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import {
|
|
6
|
+
import { prepareArtifacts } from './prepare.js';
|
|
7
|
+
import { CODE_SIZE_LIMIT } from './push.js';
|
|
6
8
|
import { pushDirectory } from './push-directory.js';
|
|
7
9
|
import { resolvePackage } from './resolve-package.js';
|
|
8
10
|
export const USAGE = `usage:
|
|
9
11
|
offsida push [dir] [--publish] [--url <u>] [--org <id>] [--allow-remote]
|
|
10
|
-
offsida build [dir] [--out <
|
|
12
|
+
offsida build [dir] [--out-dir <dir>]
|
|
11
13
|
|
|
12
14
|
dir defaults to the current directory and must contain a package.json.
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
Every file below dir is one artifact, named after the file:
|
|
17
|
+
<name>.action.ts default-exports defineAction(…); uploaded as a package holding that
|
|
18
|
+
one action, also called <name>. Names are kebab-case (a-z, 0-9, -).
|
|
19
|
+
<name>.package.ts default-exports definePackage(…) — for connections, triggers, config,
|
|
20
|
+
or several actions sharing one revision. Letters, digits, . _ -.
|
|
21
|
+
Names are per org: two packages pushing the same name overwrite each other's artifact.
|
|
22
|
+
A package file may not import an action file (that action would be published twice).
|
|
23
|
+
|
|
24
|
+
push uploads every package file, then every action file (so an action requiring a
|
|
25
|
+
package's connection gets the one just pushed), then every *.workflow.json as that
|
|
26
|
+
workflow's draft, replacing edits made in the Offsida editor. A failed upload skips
|
|
27
|
+
the workflows. Subfolders with their own package.json are separate packages and are
|
|
28
|
+
skipped, as are node_modules, dist and dot-folders. A workflow file's optional "id"
|
|
29
|
+
pins one workflow in one org; without it the workflow is matched by name or created.
|
|
30
|
+
--publish also publishes each workflow; "latest" steps lock to the code just pushed.
|
|
31
|
+
|
|
32
|
+
build bundles every artifact without uploading: with --out-dir it writes <name>.js
|
|
33
|
+
for each; without, it prints each artifact's size against the cap.
|
|
20
34
|
|
|
21
35
|
A first publish goes live with no capability confirmation (offsida.web#14). A later
|
|
22
36
|
publish that adds access is left for confirming in Offsida (exit 2). Every push mints
|
|
23
|
-
a new
|
|
37
|
+
a new revision of every artifact, so --publish republishes "latest" workflows
|
|
38
|
+
(offsida.web#15).
|
|
24
39
|
|
|
25
40
|
Auth (environment only): OFFSIDA_TOKEN, or OFFSIDA_EMAIL + OFFSIDA_CODE.
|
|
26
41
|
Scopes: code:publish; workflows:list, :get, :create, :update; workflows:publish with --publish.
|
|
@@ -30,19 +45,23 @@ Limits: name matching reads a list that lags a few seconds, so two quick pushes
|
|
|
30
45
|
new workflow can create two. Same-named workflows in different packages overwrite
|
|
31
46
|
each other without "id". Deleting a *.workflow.json leaves its workflow live.
|
|
32
47
|
|
|
48
|
+
Bundles are minified and must stay under the platform's 1 MiB cap. A rate-limited
|
|
49
|
+
request (429) waits out its Retry-After, up to a minute, and is retried.
|
|
50
|
+
|
|
33
51
|
Exit codes: 0 done, 1 something failed, 2 something needs confirming in Offsida.`;
|
|
34
52
|
// Each command's flags: `true` takes a value, `false` is a switch. Anything else is refused — in
|
|
35
53
|
// particular `--token`/`--code`, so a secret never has to sit in argv or shell history.
|
|
36
54
|
const FLAGS = {
|
|
37
55
|
push: { '--publish': false, '--url': true, '--org': true, '--allow-remote': false },
|
|
38
|
-
build: { '--out': true },
|
|
56
|
+
build: { '--out-dir': true },
|
|
39
57
|
};
|
|
40
58
|
// Flags the package-rooted `push` dropped, and where each one went.
|
|
41
59
|
const REMOVED = {
|
|
42
60
|
'--token': 'set OFFSIDA_TOKEN (or OFFSIDA_EMAIL + OFFSIDA_CODE) in the environment',
|
|
43
61
|
'--email': 'set OFFSIDA_EMAIL and OFFSIDA_CODE in the environment',
|
|
44
62
|
'--code': 'set OFFSIDA_EMAIL and OFFSIDA_CODE in the environment',
|
|
45
|
-
'--name': '
|
|
63
|
+
'--name': 'name the file <name>.action.ts or <name>.package.ts',
|
|
64
|
+
'--out': 'use --out-dir <dir>, which writes one <name>.js per artifact',
|
|
46
65
|
};
|
|
47
66
|
class UsageError extends Error {
|
|
48
67
|
}
|
|
@@ -88,16 +107,28 @@ export async function run(argv, env, deps = {}) {
|
|
|
88
107
|
const { dir, flags } = parse(command, args);
|
|
89
108
|
if (command === 'build') {
|
|
90
109
|
const pkg = await resolvePackage(dir);
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
110
|
+
for (const w of pkg.warnings)
|
|
111
|
+
err(`warning: ${w}`);
|
|
112
|
+
if (pkg.artifacts.length === 0)
|
|
113
|
+
throw new Error(`${dir}: no *.action.ts or *.package.ts files to build`);
|
|
114
|
+
const artifacts = await prepareArtifacts(pkg);
|
|
115
|
+
const outDir = flags.get('--out-dir');
|
|
116
|
+
if (typeof outDir === 'string') {
|
|
117
|
+
await mkdir(outDir, { recursive: true });
|
|
118
|
+
for (const a of artifacts) {
|
|
119
|
+
const file = join(outDir, `${a.name}.js`);
|
|
120
|
+
await writeFile(file, a.code);
|
|
121
|
+
out(`wrote ${file}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
const rows = artifacts.map((a) => [a.name, relative(pkg.dir, a.file), a.code.length]);
|
|
126
|
+
const w0 = Math.max(...rows.map((r) => r[0].length));
|
|
127
|
+
const w1 = Math.max(...rows.map((r) => r[1].length));
|
|
128
|
+
for (const [name, file, size] of rows) {
|
|
129
|
+
out(`${name.padEnd(w0)} ${file.padEnd(w1)} ${size} chars (${Math.round((size / CODE_SIZE_LIMIT) * 100)}% of 1 MiB)`);
|
|
130
|
+
}
|
|
98
131
|
}
|
|
99
|
-
else
|
|
100
|
-
out(code);
|
|
101
132
|
return 0;
|
|
102
133
|
}
|
|
103
134
|
const url = flags.get('--url');
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ArtifactFile, ResolvedPackage } from './resolve-package.js';
|
|
2
|
+
export type PreparedArtifact = ArtifactFile & {
|
|
3
|
+
code: string;
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* Bundle every artifact and run every check that needs a bundle, before anything is uploaded: the SDK
|
|
7
|
+
* an action file's wrapper imports (reported once, however many action files), a build that fails, a bundle over the platform cap, and a package
|
|
8
|
+
* file that pulls in an action file (which would publish that action twice). Every problem is reported
|
|
9
|
+
* in one error. Returned in the package's artifact order.
|
|
10
|
+
*/
|
|
11
|
+
export declare function prepareArtifacts(pkg: ResolvedPackage): Promise<PreparedArtifact[]>;
|
package/dist/prepare.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { basename, relative } from 'node:path';
|
|
2
|
+
import { bundleArtifact } from './bundle.js';
|
|
3
|
+
import { CODE_SIZE_LIMIT } from './push.js';
|
|
4
|
+
const ACTION_SUFFIX = '.action.ts';
|
|
5
|
+
const WRAPPER = '<offsida:';
|
|
6
|
+
// esbuild reports each error with a location relative to the package; the generated wrapper's is a
|
|
7
|
+
// pseudo-path, so it is named instead.
|
|
8
|
+
function buildMessage(e) {
|
|
9
|
+
const errors = e.errors;
|
|
10
|
+
if (!errors?.length)
|
|
11
|
+
return e.message;
|
|
12
|
+
return errors
|
|
13
|
+
.map((x) => {
|
|
14
|
+
if (!x.location)
|
|
15
|
+
return x.text;
|
|
16
|
+
const where = x.location.file.includes(WRAPPER) ? 'generated wrapper' : `${x.location.file}:${x.location.line}`;
|
|
17
|
+
return `${x.text} (${where})`;
|
|
18
|
+
})
|
|
19
|
+
.join('; ');
|
|
20
|
+
}
|
|
21
|
+
// The wrapper's own `import … from '@offsida/sdk'` failed: resolution is left to esbuild, the same
|
|
22
|
+
// resolver the bundle uses (Node's would also search NODE_PATH, which pnpm sets for every script).
|
|
23
|
+
const sdkMissing = (e) => !!e.errors?.some((x) => x.text === 'Could not resolve "@offsida/sdk"' && x.location?.file.includes(WRAPPER));
|
|
24
|
+
/**
|
|
25
|
+
* Bundle every artifact and run every check that needs a bundle, before anything is uploaded: the SDK
|
|
26
|
+
* an action file's wrapper imports (reported once, however many action files), a build that fails, a bundle over the platform cap, and a package
|
|
27
|
+
* file that pulls in an action file (which would publish that action twice). Every problem is reported
|
|
28
|
+
* in one error. Returned in the package's artifact order.
|
|
29
|
+
*/
|
|
30
|
+
export async function prepareArtifacts(pkg) {
|
|
31
|
+
const problems = [];
|
|
32
|
+
let noSdk = false;
|
|
33
|
+
const prepared = [];
|
|
34
|
+
for (const artifact of pkg.artifacts) {
|
|
35
|
+
const rel = relative(pkg.dir, artifact.file);
|
|
36
|
+
let bundled;
|
|
37
|
+
try {
|
|
38
|
+
bundled = await bundleArtifact(pkg.dir, artifact);
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
if (artifact.kind === 'action' && sdkMissing(e)) {
|
|
42
|
+
noSdk = true;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
problems.push(`${rel}: ${buildMessage(e)}`);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (artifact.kind === 'package') {
|
|
49
|
+
for (const input of bundled.inputs.filter((p) => p.endsWith(ACTION_SUFFIX))) {
|
|
50
|
+
const shared = basename(input).slice(0, -ACTION_SUFFIX.length);
|
|
51
|
+
problems.push(`${rel} imports ${relative(pkg.dir, input)}, which is already its own artifact — move the shared code into a plain module (e.g. ${shared}.ts) that both import`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (bundled.code.length > CODE_SIZE_LIMIT) {
|
|
55
|
+
problems.push(`${rel} bundles to ${bundled.code.length} characters; the platform cap is ${CODE_SIZE_LIMIT} (1 MiB) — trim dependencies`);
|
|
56
|
+
}
|
|
57
|
+
prepared.push({ ...artifact, code: bundled.code });
|
|
58
|
+
}
|
|
59
|
+
if (noSdk) {
|
|
60
|
+
problems.unshift(`@offsida/sdk is not installed in ${pkg.dir} — action files are wrapped in its definePackage; add it as a dependency`);
|
|
61
|
+
}
|
|
62
|
+
if (problems.length > 0)
|
|
63
|
+
throw new Error(problems.join('\n'));
|
|
64
|
+
return prepared;
|
|
65
|
+
}
|
package/dist/push-directory.d.ts
CHANGED
|
@@ -10,8 +10,8 @@ export type PushDirectoryDeps = {
|
|
|
10
10
|
idempotencyKey?: () => string;
|
|
11
11
|
};
|
|
12
12
|
/**
|
|
13
|
-
* `offsida push [dir]`:
|
|
14
|
-
* with `publish`, published — `latest` steps lock to the code just pushed). Throws only before anything
|
|
13
|
+
* `offsida push [dir]`: each *.package.ts / *.action.ts as its own artifact, then each *.workflow.json
|
|
14
|
+
* as its workflow's draft (and, with `publish`, published — `latest` steps lock to the code just pushed). Throws only before anything
|
|
15
15
|
* is pushed (pre-flight, target, auth). After that every problem is reported and the result is the worst
|
|
16
16
|
* outcome: 1 failed, 2 needs confirming in Offsida, 0 done.
|
|
17
17
|
*/
|
package/dist/push-directory.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { relative } from 'node:path';
|
|
2
2
|
import { connect } from './api.js';
|
|
3
|
-
import {
|
|
3
|
+
import { prepareArtifacts } from './prepare.js';
|
|
4
4
|
import { pushCode } from './push.js';
|
|
5
5
|
import { resolvePackage } from './resolve-package.js';
|
|
6
6
|
import { assertNoDuplicates, readWorkflowFile } from './workflow-file.js';
|
|
@@ -13,8 +13,8 @@ const SUMMARY = [
|
|
|
13
13
|
['failed', 'failed'],
|
|
14
14
|
];
|
|
15
15
|
/**
|
|
16
|
-
* `offsida push [dir]`:
|
|
17
|
-
* with `publish`, published — `latest` steps lock to the code just pushed). Throws only before anything
|
|
16
|
+
* `offsida push [dir]`: each *.package.ts / *.action.ts as its own artifact, then each *.workflow.json
|
|
17
|
+
* as its workflow's draft (and, with `publish`, published — `latest` steps lock to the code just pushed). Throws only before anything
|
|
18
18
|
* is pushed (pre-flight, target, auth). After that every problem is reported and the result is the worst
|
|
19
19
|
* outcome: 1 failed, 2 needs confirming in Offsida, 0 done.
|
|
20
20
|
*/
|
|
@@ -27,6 +27,8 @@ export async function pushDirectory(opts, deps = {}) {
|
|
|
27
27
|
};
|
|
28
28
|
// Pre-flight: everything that can be checked locally, before any request.
|
|
29
29
|
const pkg = await resolvePackage(opts.dir);
|
|
30
|
+
for (const w of pkg.warnings)
|
|
31
|
+
log(`warning: ${w}`);
|
|
30
32
|
const files = [];
|
|
31
33
|
for (const path of pkg.workflowFiles)
|
|
32
34
|
files.push(await readWorkflowFile(path));
|
|
@@ -34,21 +36,28 @@ export async function pushDirectory(opts, deps = {}) {
|
|
|
34
36
|
for (const f of files)
|
|
35
37
|
if (f.id !== undefined)
|
|
36
38
|
workflowDeps.pinned.add(f.id);
|
|
37
|
-
if (
|
|
38
|
-
throw new Error(`${opts.dir}: nothing to push — no
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
if (pkg.artifacts.length === 0 && files.length === 0) {
|
|
40
|
+
throw new Error(`${opts.dir}: nothing to push — no *.action.ts, *.package.ts or *.workflow.json files`);
|
|
41
|
+
}
|
|
42
|
+
const artifacts = await prepareArtifacts(pkg);
|
|
43
|
+
const api = await connect(opts, deps.fetch, workflowDeps.sleep);
|
|
44
|
+
// Code first, so `latest` resolves to it; packages before actions, so an action requiring a package's
|
|
45
|
+
// connection resolves to the one just pushed. The artifacts are independent uploads, so one failing
|
|
46
|
+
// doesn't stop the rest — but it stops the workflows: publishing them now would lock their steps to
|
|
47
|
+
// that artifact's previous revision.
|
|
48
|
+
let codeFailed = false;
|
|
49
|
+
for (const a of artifacts) {
|
|
43
50
|
try {
|
|
44
|
-
const { revision } = await pushCode(api,
|
|
45
|
-
log(`pushed code ${
|
|
51
|
+
const { revision } = await pushCode(api, a.name, a.code);
|
|
52
|
+
log(`pushed code ${a.name}@${revision}`);
|
|
46
53
|
}
|
|
47
54
|
catch (e) {
|
|
48
|
-
log(`error: ${e.message}`);
|
|
49
|
-
|
|
55
|
+
log(`error: ${a.name}: ${e.message}`);
|
|
56
|
+
codeFailed = true;
|
|
50
57
|
}
|
|
51
58
|
}
|
|
59
|
+
if (codeFailed)
|
|
60
|
+
return 1;
|
|
52
61
|
if (files.length === 0)
|
|
53
62
|
return 0;
|
|
54
63
|
// One list per run, and only when some file is matched by name.
|
package/dist/push.d.ts
CHANGED
package/dist/push.js
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import { basename } from 'node:path';
|
|
2
2
|
import { bundlePackage } from './bundle.js';
|
|
3
3
|
import { call, connect, failure } from './api.js';
|
|
4
|
+
// The platform's cap on an artifact's modules, counted in string length (MAX_TOTAL_MODULES_BYTES in
|
|
5
|
+
// apps/workflows/src/services/codeArtifacts.ts). Checked here so an oversized bundle fails before upload.
|
|
6
|
+
export const CODE_SIZE_LIMIT = 1024 * 1024;
|
|
4
7
|
// Upload one bundle as a new revision of artifact `name`. The build runs synchronously server-side:
|
|
5
8
|
// the response is `ready` or `failed`.
|
|
6
9
|
export async function pushCode(api, name, code) {
|
|
10
|
+
if (code.length > CODE_SIZE_LIMIT) {
|
|
11
|
+
throw new Error(`${name} bundle is ${code.length} characters; the platform cap is ${CODE_SIZE_LIMIT} (1 MiB) — trim dependencies`);
|
|
12
|
+
}
|
|
7
13
|
const res = await call(api, 'POST', '/code', { body: { name, modules: { 'index.js': code } } });
|
|
8
14
|
if (res.status === 401 || res.status === 403)
|
|
9
|
-
throw failure('pushing code', res);
|
|
15
|
+
throw failure('pushing code', res, 'code:publish');
|
|
10
16
|
const body = (res.body ?? {});
|
|
11
17
|
if (res.status !== 201 || !body.ok || !body.revision) {
|
|
12
18
|
throw new Error(`code push failed (${body.status ?? res.status}): ${body.failureReason ?? 'unknown'}`);
|
|
@@ -1,12 +1,20 @@
|
|
|
1
|
+
export type ArtifactKind = 'action' | 'package';
|
|
2
|
+
/** One artifact file: `<name>.action.ts` (one defineAction) or `<name>.package.ts` (a definePackage). */
|
|
3
|
+
export type ArtifactFile = {
|
|
4
|
+
name: string;
|
|
5
|
+
kind: ArtifactKind;
|
|
6
|
+
file: string;
|
|
7
|
+
};
|
|
1
8
|
export type ResolvedPackage = {
|
|
2
9
|
dir: string;
|
|
3
|
-
|
|
4
|
-
entry: string | null;
|
|
10
|
+
artifacts: ArtifactFile[];
|
|
5
11
|
workflowFiles: string[];
|
|
12
|
+
warnings: string[];
|
|
6
13
|
};
|
|
7
14
|
/**
|
|
8
|
-
* A package directory → what `offsida push` uploads.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
15
|
+
* A package directory → what `offsida push` uploads. Artifacts: every `<name>.action.ts` and
|
|
16
|
+
* `<name>.package.ts` below, each named after its file, packages first (an action may require a
|
|
17
|
+
* package's connection) then by name. Workflows: every *.workflow.json below. Both skip node_modules,
|
|
18
|
+
* dist, dot-dirs and nested packages. Every naming problem is reported in one error.
|
|
11
19
|
*/
|
|
12
20
|
export declare function resolvePackage(dirArg: string): Promise<ResolvedPackage>;
|
package/dist/resolve-package.js
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
2
|
-
import { join, resolve } from 'node:path';
|
|
2
|
+
import { join, relative, resolve } from 'node:path';
|
|
3
3
|
// The platform's artifact name rule (lib/contracts/src/schemas/code.ts publishBody).
|
|
4
4
|
const ARTIFACT_NAME = /^[a-zA-Z0-9._-]{1,64}$/;
|
|
5
|
+
// An action file's name is also its action's name, which the manifest schema holds to kebab-case
|
|
6
|
+
// (lib/contracts/src/schemas/connections.ts actionDefinitionSchema).
|
|
7
|
+
const ACTION_NAME = /^[a-z][a-z0-9-]{0,63}$/;
|
|
8
|
+
const SUFFIXES = [
|
|
9
|
+
['.action.ts', 'action'],
|
|
10
|
+
['.package.ts', 'package'],
|
|
11
|
+
];
|
|
5
12
|
const SKIP_DIRS = new Set(['node_modules', 'dist']);
|
|
6
13
|
async function isFile(path) {
|
|
7
14
|
try {
|
|
@@ -11,35 +18,39 @@ async function isFile(path) {
|
|
|
11
18
|
return false;
|
|
12
19
|
}
|
|
13
20
|
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
return dot;
|
|
18
|
-
if (dot && typeof dot === 'object') {
|
|
19
|
-
const c = dot;
|
|
20
|
-
for (const k of ['import', 'default'])
|
|
21
|
-
if (typeof c[k] === 'string')
|
|
22
|
-
return c[k];
|
|
23
|
-
}
|
|
24
|
-
return undefined;
|
|
25
|
-
}
|
|
26
|
-
async function findWorkflowFiles(dir) {
|
|
27
|
-
const out = [];
|
|
21
|
+
// A subdirectory with its own package.json is another package: its files go with its own push, not
|
|
22
|
+
// this one's (pushing them from a monorepo root would upload them without their dependencies).
|
|
23
|
+
async function walk(dir, out) {
|
|
28
24
|
for (const e of await readdir(dir, { withFileTypes: true })) {
|
|
29
25
|
const path = join(dir, e.name);
|
|
30
26
|
if (e.isDirectory()) {
|
|
31
|
-
if (
|
|
32
|
-
|
|
27
|
+
if (SKIP_DIRS.has(e.name) || e.name.startsWith('.') || (await isFile(join(path, 'package.json'))))
|
|
28
|
+
continue;
|
|
29
|
+
await walk(path, out);
|
|
30
|
+
}
|
|
31
|
+
else if (e.isFile()) {
|
|
32
|
+
if (e.name.endsWith('.workflow.json'))
|
|
33
|
+
out.workflowFiles.push(path);
|
|
34
|
+
const match = SUFFIXES.find(([suffix]) => e.name.endsWith(suffix));
|
|
35
|
+
if (match)
|
|
36
|
+
out.artifacts.push({ name: e.name.slice(0, -match[0].length), kind: match[1], file: path });
|
|
33
37
|
}
|
|
34
|
-
else if (e.isFile() && e.name.endsWith('.workflow.json'))
|
|
35
|
-
out.push(path);
|
|
36
38
|
}
|
|
37
|
-
return out;
|
|
38
39
|
}
|
|
40
|
+
function suggestActionName(name) {
|
|
41
|
+
const s = name
|
|
42
|
+
.toLowerCase()
|
|
43
|
+
.replace(/[^a-z0-9-]+/g, '-')
|
|
44
|
+
.replace(/^[^a-z]+/, '')
|
|
45
|
+
.replace(/-+$/, '');
|
|
46
|
+
return s || 'my-action';
|
|
47
|
+
}
|
|
48
|
+
const byKindThenName = (a, b) => (a.kind === b.kind ? 0 : a.kind === 'package' ? -1 : 1) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
39
49
|
/**
|
|
40
|
-
* A package directory → what `offsida push` uploads.
|
|
41
|
-
*
|
|
42
|
-
*
|
|
50
|
+
* A package directory → what `offsida push` uploads. Artifacts: every `<name>.action.ts` and
|
|
51
|
+
* `<name>.package.ts` below, each named after its file, packages first (an action may require a
|
|
52
|
+
* package's connection) then by name. Workflows: every *.workflow.json below. Both skip node_modules,
|
|
53
|
+
* dist, dot-dirs and nested packages. Every naming problem is reported in one error.
|
|
43
54
|
*/
|
|
44
55
|
export async function resolvePackage(dirArg) {
|
|
45
56
|
const dir = resolve(dirArg);
|
|
@@ -49,7 +60,7 @@ export async function resolvePackage(dirArg) {
|
|
|
49
60
|
raw = await readFile(pkgPath, 'utf8');
|
|
50
61
|
}
|
|
51
62
|
catch {
|
|
52
|
-
throw new Error(`${dirArg}: no package.json — offsida
|
|
63
|
+
throw new Error(`${dirArg}: no package.json — run offsida in a package directory`);
|
|
53
64
|
}
|
|
54
65
|
let pkg;
|
|
55
66
|
try {
|
|
@@ -58,22 +69,30 @@ export async function resolvePackage(dirArg) {
|
|
|
58
69
|
catch {
|
|
59
70
|
throw new Error(`${pkgPath}: not valid JSON`);
|
|
60
71
|
}
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (!artifactName)
|
|
65
|
-
throw new Error(`${pkgPath}: no "name" (or "offsida.name") to name the artifact`);
|
|
66
|
-
if (!ARTIFACT_NAME.test(artifactName)) {
|
|
67
|
-
throw new Error(`artifact name "${artifactName}" is not allowed (letters, digits, . _ -, max 64) — set "offsida": { "name": "…" } in package.json`);
|
|
72
|
+
const warnings = [];
|
|
73
|
+
if (pkg.offsida?.name !== undefined) {
|
|
74
|
+
warnings.push('package.json: "offsida.name" is ignored — artifacts are named after their *.action.ts / *.package.ts files');
|
|
68
75
|
}
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
76
|
+
const found = { artifacts: [], workflowFiles: [] };
|
|
77
|
+
await walk(dir, found);
|
|
78
|
+
const artifacts = found.artifacts.sort(byKindThenName);
|
|
79
|
+
const problems = [];
|
|
80
|
+
const seen = new Map();
|
|
81
|
+
for (const a of artifacts) {
|
|
82
|
+
const rel = relative(dir, a.file);
|
|
83
|
+
if (a.kind === 'action' && !ACTION_NAME.test(a.name)) {
|
|
84
|
+
problems.push(`${rel}: an action file name must be kebab-case (a-z, 0-9, -; starting with a letter; max 64) — it is also the action's name. Try ${suggestActionName(a.name)}.action.ts`);
|
|
85
|
+
}
|
|
86
|
+
else if (!ARTIFACT_NAME.test(a.name)) {
|
|
87
|
+
problems.push(`${rel}: "${a.name}" is not a valid artifact name (letters, digits, . _ -, max 64)`);
|
|
75
88
|
}
|
|
89
|
+
const first = seen.get(a.name);
|
|
90
|
+
if (first)
|
|
91
|
+
problems.push(`"${a.name}" is declared by both ${first} and ${rel} — artifact names are per org, so each file name must be unique`);
|
|
92
|
+
else
|
|
93
|
+
seen.set(a.name, rel);
|
|
76
94
|
}
|
|
77
|
-
|
|
78
|
-
|
|
95
|
+
if (problems.length > 0)
|
|
96
|
+
throw new Error(problems.join('\n'));
|
|
97
|
+
return { dir, artifacts, workflowFiles: found.workflowFiles.sort(), warnings };
|
|
79
98
|
}
|
package/dist/workflows.js
CHANGED
|
@@ -12,7 +12,7 @@ export async function listWorkflows(api) {
|
|
|
12
12
|
for (;;) {
|
|
13
13
|
const res = await call(api, 'GET', `/workflows?limit=${PAGE}${cursor === undefined ? '' : `&cursor=${cursor}`}`);
|
|
14
14
|
if (res.status !== 200)
|
|
15
|
-
throw failure('listing workflows', res);
|
|
15
|
+
throw failure('listing workflows', res, 'workflows:list');
|
|
16
16
|
const items = (res.body?.items ?? []);
|
|
17
17
|
const before = rows.size;
|
|
18
18
|
for (const r of items)
|
|
@@ -41,7 +41,7 @@ export async function findWorkflow(api, file, rows, pinned = new Set()) {
|
|
|
41
41
|
throw new Error(`no workflow ${file.id} in this org — "id" ties a file to one org; fix it, or remove it to match by name`);
|
|
42
42
|
}
|
|
43
43
|
if (res.status !== 200 || !wf)
|
|
44
|
-
throw failure('reading workflow', res);
|
|
44
|
+
throw failure('reading workflow', res, 'workflows:get');
|
|
45
45
|
return { kind: 'existing', id: wf.id, currentName: wf.name };
|
|
46
46
|
}
|
|
47
47
|
const matches = rows.filter((r) => r.name === file.name);
|
|
@@ -57,12 +57,66 @@ export async function findWorkflow(api, file, rows, pinned = new Set()) {
|
|
|
57
57
|
}
|
|
58
58
|
const { res, wf } = await readWorkflow(api, id);
|
|
59
59
|
if (res.status !== 200 || !wf)
|
|
60
|
-
throw failure('reading workflow', res);
|
|
60
|
+
throw failure('reading workflow', res, 'workflows:get');
|
|
61
61
|
if (wf.name !== file.name)
|
|
62
62
|
throw new Error(`workflow ${id} changed while resolving "${file.name}" — retry`);
|
|
63
63
|
return { kind: 'existing', id: wf.id, currentName: wf.name };
|
|
64
64
|
}
|
|
65
|
-
const CAPABILITIES = ['secrets', 'connections', 'egressDomains', 'artifacts', 'containerImages'];
|
|
65
|
+
const CAPABILITIES = ['secrets', 'connections', 'egressDomains', 'artifacts', 'containerImages', 'fileStores'];
|
|
66
|
+
/** `${stepId}:${storeId}:${mode}` (the CLI stays free of the contracts package, so this mirrors `parseFileStoreEntry`). */
|
|
67
|
+
function parseGrant(entry) {
|
|
68
|
+
const parts = entry.split(':');
|
|
69
|
+
const mode = parts.pop() ?? '';
|
|
70
|
+
const store = parts.pop() ?? '';
|
|
71
|
+
return { stepId: parts.join(':'), store, mode };
|
|
72
|
+
}
|
|
73
|
+
/** Every store id the definition binds, at any depth (nested steps included). */
|
|
74
|
+
function boundStoreIds(node, into = new Set()) {
|
|
75
|
+
if (Array.isArray(node))
|
|
76
|
+
for (const v of node)
|
|
77
|
+
boundStoreIds(v, into);
|
|
78
|
+
else if (node && typeof node === 'object') {
|
|
79
|
+
for (const [k, v] of Object.entries(node)) {
|
|
80
|
+
if (k === 'fileStoreBindings' && v && typeof v === 'object') {
|
|
81
|
+
for (const b of Object.values(v)) {
|
|
82
|
+
const id = b?.storeId;
|
|
83
|
+
if (typeof id === 'string')
|
|
84
|
+
into.add(id);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
else
|
|
88
|
+
boundStoreIds(v, into);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return into;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The org's stores by id, best effort: `undefined` when the list cannot be read (a token without `files:list`, a network
|
|
95
|
+
* error) — the escalation is then still shown, by store id, with no SENSITIVE marker and a note that sensitivity is unknown.
|
|
96
|
+
*/
|
|
97
|
+
async function loadStores(api) {
|
|
98
|
+
try {
|
|
99
|
+
const res = await call(api, 'GET', '/files');
|
|
100
|
+
const items = res.body?.items;
|
|
101
|
+
if (res.status !== 200 || !Array.isArray(items))
|
|
102
|
+
return undefined;
|
|
103
|
+
return new Map(items.map((s) => [s.storeId, { name: s.name, sensitive: s.sensitive }]));
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* One file-store grant, readably: `step <id>: <store name | run store> — <mode>`, with a SENSITIVE marker on a sensitive
|
|
111
|
+
* store and on a run-store grant in a workflow that holds a sensitive binding.
|
|
112
|
+
*/
|
|
113
|
+
function grantLine(entry, stores, workflowSensitive) {
|
|
114
|
+
const { stepId, store, mode } = parseGrant(entry);
|
|
115
|
+
const info = stores.get(store);
|
|
116
|
+
const isRun = store === 'run';
|
|
117
|
+
const sensitive = isRun ? workflowSensitive : (info?.sensitive ?? false);
|
|
118
|
+
return `step ${stepId}: ${isRun ? 'run store' : (info?.name ?? store)} — ${mode}${sensitive ? ' [SENSITIVE]' : ''}`;
|
|
119
|
+
}
|
|
66
120
|
const indent = (text) => text.split('\n').map((l) => ` ${l}`);
|
|
67
121
|
/**
|
|
68
122
|
* Save one workflow file as its workflow's draft and, with `publish`, publish it. Never throws: every
|
|
@@ -90,12 +144,12 @@ async function saveAndPublish(api, file, rows, publish, deps, say) {
|
|
|
90
144
|
const key = deps.idempotencyKey();
|
|
91
145
|
res = await withRetry(() => call(api, 'POST', '/workflows', { body, headers: { 'Idempotency-Key': key } }), deps.sleep);
|
|
92
146
|
if (res.status !== 201)
|
|
93
|
-
throw failure('saving workflow', res);
|
|
147
|
+
throw failure('saving workflow', res, 'workflows:create');
|
|
94
148
|
}
|
|
95
149
|
else {
|
|
96
150
|
res = await call(api, 'PUT', `/workflows/${encodeURIComponent(target.id)}`, { body });
|
|
97
151
|
if (res.status !== 200)
|
|
98
|
-
throw failure('saving workflow', res);
|
|
152
|
+
throw failure('saving workflow', res, 'workflows:update');
|
|
99
153
|
}
|
|
100
154
|
const saved = res.body;
|
|
101
155
|
const id = saved.workflow.id;
|
|
@@ -138,11 +192,23 @@ async function saveAndPublish(api, file, rows, publish, deps, say) {
|
|
|
138
192
|
say('needs confirming in Offsida — the draft is uploaded but adds access:');
|
|
139
193
|
for (const c of CAPABILITIES) {
|
|
140
194
|
const added = b.escalation?.[c]?.added ?? [];
|
|
141
|
-
if (added.length)
|
|
195
|
+
if (!added.length)
|
|
196
|
+
continue;
|
|
197
|
+
if (c !== 'fileStores') {
|
|
142
198
|
say(` ${c}: ${added.join(', ')}`);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const loaded = await loadStores(api);
|
|
202
|
+
const stores = loaded ?? new Map();
|
|
203
|
+
const workflowSensitive = [...boundStoreIds(file.definition)].some((id) => stores.get(id)?.sensitive);
|
|
204
|
+
say(` ${c}:`);
|
|
205
|
+
for (const e of added)
|
|
206
|
+
say(` ${grantLine(e, stores, workflowSensitive)}`);
|
|
207
|
+
if (!loaded)
|
|
208
|
+
say(' (the store list could not be read, so sensitivity is unknown — confirm in the web UI)');
|
|
143
209
|
}
|
|
144
210
|
say(`confirm and publish at ${editorUrl}`);
|
|
145
211
|
return 'confirm';
|
|
146
212
|
}
|
|
147
|
-
throw failure('publishing workflow', res);
|
|
213
|
+
throw failure('publishing workflow', res, 'workflows:publish');
|
|
148
214
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@offsida/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The `offsida` CLI — bundle an integration package and publish it to a running Offsida environment.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"esbuild-wasm": "^0.25.12",
|
|
40
|
-
"@offsida/types": "0.
|
|
40
|
+
"@offsida/types": "0.2.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/node": "^25.9.3",
|