@tagma/sdk 0.7.4 → 0.7.6
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 +60 -53
- package/dist/completions/file-exists.js +1 -1
- package/dist/completions/file-exists.js.map +1 -1
- package/dist/completions/output-check.d.ts.map +1 -1
- package/dist/completions/output-check.js +17 -4
- package/dist/completions/output-check.js.map +1 -1
- package/dist/config.d.ts +4 -4
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -2
- package/dist/config.js.map +1 -1
- package/dist/dataflow.d.ts +3 -0
- package/dist/dataflow.d.ts.map +1 -0
- package/dist/dataflow.js +2 -0
- package/dist/dataflow.js.map +1 -0
- package/dist/drivers/opencode.d.ts.map +1 -1
- package/dist/drivers/opencode.js +23 -71
- package/dist/drivers/opencode.js.map +1 -1
- package/dist/middlewares/static-context.d.ts.map +1 -1
- package/dist/middlewares/static-context.js +1 -2
- package/dist/middlewares/static-context.js.map +1 -1
- package/dist/pipeline-runner.d.ts.map +1 -1
- package/dist/pipeline-runner.js +2 -2
- package/dist/pipeline-runner.js.map +1 -1
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +3 -4
- package/dist/schema.js.map +1 -1
- package/dist/triggers/file.d.ts.map +1 -1
- package/dist/triggers/file.js +1 -2
- package/dist/triggers/file.js.map +1 -1
- package/dist/triggers/manual.d.ts.map +1 -1
- package/dist/triggers/manual.js +1 -2
- package/dist/triggers/manual.js.map +1 -1
- package/dist/types.d.ts +1 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -12
- package/dist/types.js.map +1 -1
- package/dist/utils-api.d.ts +1 -1
- package/dist/utils-api.d.ts.map +1 -1
- package/dist/utils-api.js +1 -1
- package/dist/utils-api.js.map +1 -1
- package/dist/validate-raw.d.ts +4 -4
- package/dist/validate-raw.d.ts.map +1 -1
- package/dist/validate-raw.js +45 -52
- package/dist/validate-raw.js.map +1 -1
- package/package.json +11 -24
- package/dist/adapters/stdin-approval.d.ts +0 -2
- package/dist/adapters/stdin-approval.d.ts.map +0 -1
- package/dist/adapters/stdin-approval.js +0 -2
- package/dist/adapters/stdin-approval.js.map +0 -1
- package/dist/adapters/websocket-approval.d.ts +0 -2
- package/dist/adapters/websocket-approval.d.ts.map +0 -1
- package/dist/adapters/websocket-approval.js +0 -2
- package/dist/adapters/websocket-approval.js.map +0 -1
- package/dist/core/dataflow.d.ts +0 -23
- package/dist/core/dataflow.d.ts.map +0 -1
- package/dist/core/dataflow.js +0 -99
- package/dist/core/dataflow.js.map +0 -1
- package/dist/core/log-prune.d.ts +0 -16
- package/dist/core/log-prune.d.ts.map +0 -1
- package/dist/core/log-prune.js +0 -34
- package/dist/core/log-prune.js.map +0 -1
- package/dist/core/preflight.d.ts +0 -13
- package/dist/core/preflight.d.ts.map +0 -1
- package/dist/core/preflight.js +0 -61
- package/dist/core/preflight.js.map +0 -1
- package/dist/core/run-context.d.ts +0 -55
- package/dist/core/run-context.d.ts.map +0 -1
- package/dist/core/run-context.js +0 -158
- package/dist/core/run-context.js.map +0 -1
- package/dist/core/run-state.d.ts +0 -25
- package/dist/core/run-state.d.ts.map +0 -1
- package/dist/core/run-state.js +0 -93
- package/dist/core/run-state.js.map +0 -1
- package/dist/core/scheduler.d.ts +0 -13
- package/dist/core/scheduler.d.ts.map +0 -1
- package/dist/core/scheduler.js +0 -35
- package/dist/core/scheduler.js.map +0 -1
- package/dist/core/task-executor.d.ts +0 -13
- package/dist/core/task-executor.d.ts.map +0 -1
- package/dist/core/task-executor.js +0 -610
- package/dist/core/task-executor.js.map +0 -1
- package/dist/core/trigger-errors.d.ts +0 -9
- package/dist/core/trigger-errors.d.ts.map +0 -1
- package/dist/core/trigger-errors.js +0 -15
- package/dist/core/trigger-errors.js.map +0 -1
- package/dist/dag.d.ts +0 -45
- package/dist/dag.d.ts.map +0 -1
- package/dist/dag.js +0 -177
- package/dist/dag.js.map +0 -1
- package/dist/hooks.d.ts +0 -73
- package/dist/hooks.d.ts.map +0 -1
- package/dist/hooks.js +0 -106
- package/dist/hooks.js.map +0 -1
- package/dist/pipeline-definition.d.ts +0 -3
- package/dist/pipeline-definition.d.ts.map +0 -1
- package/dist/pipeline-definition.js +0 -4
- package/dist/pipeline-definition.js.map +0 -1
- package/dist/ports.d.ts +0 -196
- package/dist/ports.d.ts.map +0 -1
- package/dist/ports.js +0 -688
- package/dist/ports.js.map +0 -1
- package/dist/prompt-doc.d.ts +0 -70
- package/dist/prompt-doc.d.ts.map +0 -1
- package/dist/prompt-doc.js +0 -154
- package/dist/prompt-doc.js.map +0 -1
- package/dist/registry.d.ts +0 -3
- package/dist/registry.d.ts.map +0 -1
- package/dist/registry.js +0 -2
- package/dist/registry.js.map +0 -1
- package/dist/task-ref.d.ts +0 -55
- package/dist/task-ref.d.ts.map +0 -1
- package/dist/task-ref.js +0 -103
- package/dist/task-ref.js.map +0 -1
- package/dist/utils.d.ts +0 -13
- package/dist/utils.d.ts.map +0 -1
- package/dist/utils.js +0 -177
- package/dist/utils.js.map +0 -1
- package/src/adapters/stdin-approval.ts +0 -1
- package/src/adapters/websocket-approval.ts +0 -1
- package/src/approval.ts +0 -9
- package/src/bootstrap.ts +0 -55
- package/src/completions/exit-code.ts +0 -34
- package/src/completions/file-exists.ts +0 -66
- package/src/completions/output-check.test.ts +0 -50
- package/src/completions/output-check.ts +0 -92
- package/src/config-ops.test.ts +0 -70
- package/src/config-ops.ts +0 -328
- package/src/config.ts +0 -26
- package/src/core/dataflow.test.ts +0 -166
- package/src/core/dataflow.ts +0 -161
- package/src/core/log-prune.test.ts +0 -58
- package/src/core/log-prune.ts +0 -43
- package/src/core/preflight.test.ts +0 -49
- package/src/core/preflight.ts +0 -89
- package/src/core/run-context.test.ts +0 -291
- package/src/core/run-context.ts +0 -211
- package/src/core/run-state.test.ts +0 -98
- package/src/core/run-state.ts +0 -122
- package/src/core/scheduler.test.ts +0 -83
- package/src/core/scheduler.ts +0 -42
- package/src/core/task-executor.ts +0 -752
- package/src/core/trigger-errors.ts +0 -15
- package/src/dag.test.ts +0 -56
- package/src/dag.ts +0 -245
- package/src/drivers/opencode.ts +0 -410
- package/src/engine-ports-mixed.test.ts +0 -182
- package/src/engine-ports.test.ts +0 -210
- package/src/engine-task-type.test.ts +0 -56
- package/src/engine.ts +0 -32
- package/src/hooks.ts +0 -193
- package/src/index.ts +0 -31
- package/src/logger.ts +0 -2
- package/src/middlewares/static-context.ts +0 -49
- package/src/package-split.test.ts +0 -15
- package/src/pipeline-definition.ts +0 -5
- package/src/pipeline-runner.test.ts +0 -144
- package/src/pipeline-runner.ts +0 -194
- package/src/plugin-registry.test.ts +0 -448
- package/src/plugins.ts +0 -21
- package/src/ports.test.ts +0 -678
- package/src/ports.ts +0 -925
- package/src/prompt-doc.test.ts +0 -174
- package/src/prompt-doc.ts +0 -169
- package/src/registry.ts +0 -7
- package/src/runner.test.ts +0 -142
- package/src/runner.ts +0 -1
- package/src/runtime/adapters/stdin-approval.ts +0 -1
- package/src/runtime/adapters/websocket-approval.ts +0 -1
- package/src/runtime/bun-process-runner.ts +0 -1
- package/src/runtime-adapters.test.ts +0 -10
- package/src/runtime.ts +0 -12
- package/src/schema-ports.test.ts +0 -172
- package/src/schema.test.ts +0 -213
- package/src/schema.ts +0 -379
- package/src/tagma.test.ts +0 -317
- package/src/tagma.ts +0 -67
- package/src/task-ref.test.ts +0 -401
- package/src/task-ref.ts +0 -121
- package/src/triggers/file.test.ts +0 -79
- package/src/triggers/file.ts +0 -131
- package/src/triggers/manual.ts +0 -86
- package/src/types.ts +0 -18
- package/src/utils-api.ts +0 -8
- package/src/utils.test.ts +0 -28
- package/src/utils.ts +0 -203
- package/src/validate-raw-plugin-types.test.ts +0 -60
- package/src/validate-raw-ports.test.ts +0 -136
- package/src/validate-raw.ts +0 -852
- package/src/yaml-compiler.test.ts +0 -108
- package/src/yaml-compiler.ts +0 -110
- package/src/yaml.ts +0 -11
package/src/triggers/file.ts
DELETED
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
import { resolve, dirname } from 'path';
|
|
2
|
-
import type { TriggerPlugin, TriggerContext } from '../types';
|
|
3
|
-
import { parseDuration, validatePath } from '../utils';
|
|
4
|
-
import { TriggerTimeoutError } from '../core/trigger-errors';
|
|
5
|
-
|
|
6
|
-
const IS_WINDOWS = process.platform === 'win32';
|
|
7
|
-
|
|
8
|
-
function pathsEqual(a: string, b: string): boolean {
|
|
9
|
-
return IS_WINDOWS ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export const FileTrigger: TriggerPlugin = {
|
|
13
|
-
name: 'file',
|
|
14
|
-
schema: {
|
|
15
|
-
description: 'Wait for a file to appear or be modified before the task runs.',
|
|
16
|
-
fields: {
|
|
17
|
-
path: {
|
|
18
|
-
type: 'path',
|
|
19
|
-
required: true,
|
|
20
|
-
description: 'Path to the file to watch (relative to workDir or absolute).',
|
|
21
|
-
placeholder: 'e.g. build/output.json',
|
|
22
|
-
},
|
|
23
|
-
timeout: {
|
|
24
|
-
type: 'duration',
|
|
25
|
-
description: 'Maximum wait time (e.g. 30s, 5m). Omit or 0 to wait indefinitely.',
|
|
26
|
-
placeholder: '30s',
|
|
27
|
-
},
|
|
28
|
-
},
|
|
29
|
-
},
|
|
30
|
-
|
|
31
|
-
watch(config: Record<string, unknown>, ctx: TriggerContext): Promise<unknown> {
|
|
32
|
-
const filePath = config.path as string;
|
|
33
|
-
if (!filePath) throw new Error(`file trigger: "path" is required`);
|
|
34
|
-
|
|
35
|
-
const safePath = validatePath(filePath, ctx.workDir);
|
|
36
|
-
const timeoutMs = config.timeout != null ? parseDuration(String(config.timeout)) : 0;
|
|
37
|
-
|
|
38
|
-
return waitForFile({ filePath, safePath, timeoutMs, timeoutLabel: config.timeout, ctx });
|
|
39
|
-
},
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
async function waitForFile(options: {
|
|
43
|
-
readonly filePath: string;
|
|
44
|
-
readonly safePath: string;
|
|
45
|
-
readonly timeoutMs: number;
|
|
46
|
-
readonly timeoutLabel: unknown;
|
|
47
|
-
readonly ctx: TriggerContext;
|
|
48
|
-
}): Promise<unknown> {
|
|
49
|
-
const { filePath, safePath, timeoutMs, timeoutLabel, ctx } = options;
|
|
50
|
-
if (ctx.signal.aborted) throw new Error('Pipeline aborted');
|
|
51
|
-
|
|
52
|
-
const dir = dirname(safePath);
|
|
53
|
-
await ctx.runtime.ensureDir(dir).catch(() => {
|
|
54
|
-
/* best effort; runtime watch will surface real failures */
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
const watchController = new AbortController();
|
|
58
|
-
let removeAbortListener = () => {
|
|
59
|
-
/* no-op until the abort listener is installed */
|
|
60
|
-
};
|
|
61
|
-
const abortPromise = new Promise<never>((_, reject) => {
|
|
62
|
-
const onAbort = () => {
|
|
63
|
-
watchController.abort();
|
|
64
|
-
reject(new Error('Pipeline aborted'));
|
|
65
|
-
};
|
|
66
|
-
ctx.signal.addEventListener('abort', onAbort, { once: true });
|
|
67
|
-
removeAbortListener = () => ctx.signal.removeEventListener('abort', onAbort);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
71
|
-
const timeoutPromise =
|
|
72
|
-
timeoutMs > 0
|
|
73
|
-
? new Promise<never>((_, reject) => {
|
|
74
|
-
timer = setTimeout(() => {
|
|
75
|
-
watchController.abort();
|
|
76
|
-
reject(
|
|
77
|
-
new TriggerTimeoutError(
|
|
78
|
-
`file trigger timeout: ${filePath} did not appear within ${timeoutLabel}`,
|
|
79
|
-
),
|
|
80
|
-
);
|
|
81
|
-
}, timeoutMs);
|
|
82
|
-
})
|
|
83
|
-
: new Promise<never>(() => {
|
|
84
|
-
/* no timeout */
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
async function watchLoop(): Promise<unknown> {
|
|
88
|
-
// Pass `cwd: dir` so runtimes can emit paths relative to the watched
|
|
89
|
-
// directory. The 'add'/'change' events are resolved against `dir` before
|
|
90
|
-
// comparison, preserving the old chokidar behavior without coupling this
|
|
91
|
-
// trigger to chokidar or Bun file APIs.
|
|
92
|
-
for await (const event of ctx.runtime.watch(dir, {
|
|
93
|
-
ignoreInitial: true,
|
|
94
|
-
depth: 0,
|
|
95
|
-
cwd: dir,
|
|
96
|
-
awaitWriteFinishMs: 100,
|
|
97
|
-
signal: watchController.signal,
|
|
98
|
-
})) {
|
|
99
|
-
if (event.type === 'ready') {
|
|
100
|
-
let exists = false;
|
|
101
|
-
try {
|
|
102
|
-
exists = await ctx.runtime.fileExists(safePath);
|
|
103
|
-
} catch (err) {
|
|
104
|
-
throw new Error(
|
|
105
|
-
`file trigger existence check failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
106
|
-
);
|
|
107
|
-
}
|
|
108
|
-
if (exists) return { path: safePath };
|
|
109
|
-
continue;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
if (
|
|
113
|
-
(event.type === 'add' || event.type === 'change') &&
|
|
114
|
-
pathsEqual(resolve(dir, event.path), safePath)
|
|
115
|
-
) {
|
|
116
|
-
return { path: safePath };
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
if (ctx.signal.aborted) throw new Error('Pipeline aborted');
|
|
121
|
-
throw new Error(`file trigger watch ended before ${filePath} appeared`);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
try {
|
|
125
|
-
return await Promise.race([watchLoop(), timeoutPromise, abortPromise]);
|
|
126
|
-
} finally {
|
|
127
|
-
if (timer !== null) clearTimeout(timer);
|
|
128
|
-
removeAbortListener();
|
|
129
|
-
watchController.abort();
|
|
130
|
-
}
|
|
131
|
-
}
|
package/src/triggers/manual.ts
DELETED
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
import type { TriggerPlugin, TriggerContext } from '../types';
|
|
2
|
-
import { parseDuration } from '../utils';
|
|
3
|
-
import { TriggerBlockedError, TriggerTimeoutError } from '../engine';
|
|
4
|
-
|
|
5
|
-
export const ManualTrigger: TriggerPlugin = {
|
|
6
|
-
name: 'manual',
|
|
7
|
-
schema: {
|
|
8
|
-
description: 'Pause the task until a user approves via the approval gateway.',
|
|
9
|
-
fields: {
|
|
10
|
-
message: {
|
|
11
|
-
type: 'string',
|
|
12
|
-
description: 'Prompt shown to the approver. Defaults to a generic message if empty.',
|
|
13
|
-
placeholder: 'Confirm deployment to production?',
|
|
14
|
-
},
|
|
15
|
-
timeout: {
|
|
16
|
-
type: 'duration',
|
|
17
|
-
description: 'Maximum wait time (e.g. 10m). Omit or 0 to wait indefinitely.',
|
|
18
|
-
placeholder: '10m',
|
|
19
|
-
},
|
|
20
|
-
},
|
|
21
|
-
},
|
|
22
|
-
|
|
23
|
-
async watch(config: Record<string, unknown>, ctx: TriggerContext): Promise<unknown> {
|
|
24
|
-
const message =
|
|
25
|
-
(config.message as string | undefined) ??
|
|
26
|
-
`Manual confirmation required for task "${ctx.taskId}"`;
|
|
27
|
-
const timeoutMs = config.timeout ? parseDuration(config.timeout as string) : 0;
|
|
28
|
-
const metadata =
|
|
29
|
-
config.metadata && typeof config.metadata === 'object'
|
|
30
|
-
? (config.metadata as Record<string, unknown>)
|
|
31
|
-
: undefined;
|
|
32
|
-
|
|
33
|
-
const decisionPromise = ctx.approvalGateway.request({
|
|
34
|
-
taskId: ctx.taskId,
|
|
35
|
-
trackId: ctx.trackId,
|
|
36
|
-
message,
|
|
37
|
-
timeoutMs,
|
|
38
|
-
metadata,
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
// Wire AbortSignal → try to resolve this specific request as aborted.
|
|
42
|
-
// We can't directly cancel via the gateway (no id yet at .request() call site),
|
|
43
|
-
// so instead we race against an abort promise and let engine status logic
|
|
44
|
-
// fall back to pipelineAborted → skipped. abortAll() on gateway still runs
|
|
45
|
-
// from engine shutdown path to clean up any truly-pending entries.
|
|
46
|
-
const onAbort = () => {};
|
|
47
|
-
const abortPromise = new Promise<never>((_, reject) => {
|
|
48
|
-
if (ctx.signal.aborted) {
|
|
49
|
-
reject(new Error('Pipeline aborted'));
|
|
50
|
-
return;
|
|
51
|
-
}
|
|
52
|
-
const handler = () => reject(new Error('Pipeline aborted'));
|
|
53
|
-
// Store reference so we can remove it after the race settles.
|
|
54
|
-
(onAbort as { handler?: () => void }).handler = handler;
|
|
55
|
-
ctx.signal.addEventListener('abort', handler, { once: true });
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
let decision: Awaited<typeof decisionPromise>;
|
|
59
|
-
try {
|
|
60
|
-
decision = await Promise.race([decisionPromise, abortPromise]);
|
|
61
|
-
} finally {
|
|
62
|
-
// Clean up the abort listener to prevent leaking on normal completion.
|
|
63
|
-
const handler = (onAbort as { handler?: () => void }).handler;
|
|
64
|
-
if (handler) ctx.signal.removeEventListener('abort', handler);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
switch (decision.outcome) {
|
|
68
|
-
case 'approved':
|
|
69
|
-
return { confirmed: true, approvalId: decision.approvalId, actor: decision.actor };
|
|
70
|
-
case 'rejected':
|
|
71
|
-
// A7: Use typed error for proper classification in the engine.
|
|
72
|
-
throw new TriggerBlockedError(
|
|
73
|
-
`Manual trigger rejected by ${decision.actor ?? 'user'}` +
|
|
74
|
-
(decision.reason ? `: ${decision.reason}` : ''),
|
|
75
|
-
);
|
|
76
|
-
case 'timeout':
|
|
77
|
-
throw new TriggerTimeoutError(
|
|
78
|
-
`Manual trigger timeout: ${decision.reason ?? 'no decision made'}`,
|
|
79
|
-
);
|
|
80
|
-
case 'aborted':
|
|
81
|
-
throw new TriggerBlockedError(
|
|
82
|
-
`Manual trigger aborted: ${decision.reason ?? 'pipeline aborted'}`,
|
|
83
|
-
);
|
|
84
|
-
}
|
|
85
|
-
},
|
|
86
|
-
};
|
package/src/types.ts
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
// ═══ Engine-facing type surface ═══
|
|
2
|
-
//
|
|
3
|
-
// All type definitions live in the shared `@tagma/types` workspace package
|
|
4
|
-
// so that plugins under plugins/* can depend on the same types without
|
|
5
|
-
// reaching into the engine's internals. This file re-exports everything
|
|
6
|
-
// and adds runtime-only values (constants) that plugins don't need.
|
|
7
|
-
|
|
8
|
-
export * from '@tagma/types';
|
|
9
|
-
|
|
10
|
-
import type { Permissions } from '@tagma/types';
|
|
11
|
-
|
|
12
|
-
// ═══ Runtime Constants ═══
|
|
13
|
-
|
|
14
|
-
export const DEFAULT_PERMISSIONS: Permissions = {
|
|
15
|
-
read: true,
|
|
16
|
-
write: false,
|
|
17
|
-
execute: false,
|
|
18
|
-
};
|
package/src/utils-api.ts
DELETED
package/src/utils.test.ts
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
-
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
import { validatePath } from './utils';
|
|
6
|
-
|
|
7
|
-
describe('validatePath', () => {
|
|
8
|
-
test('rejects real parent traversal outside the project root', () => {
|
|
9
|
-
const root = mkdtempSync(join(tmpdir(), 'tagma-validate-path-'));
|
|
10
|
-
try {
|
|
11
|
-
expect(() => validatePath('../outside', root)).toThrow(/escapes project root/);
|
|
12
|
-
} finally {
|
|
13
|
-
rmSync(root, { recursive: true, force: true });
|
|
14
|
-
}
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
test('allows project-local names that merely start with two dots', () => {
|
|
18
|
-
const root = mkdtempSync(join(tmpdir(), 'tagma-validate-path-'));
|
|
19
|
-
try {
|
|
20
|
-
const inside = join(root, '..inside');
|
|
21
|
-
mkdirSync(inside);
|
|
22
|
-
|
|
23
|
-
expect(validatePath('..inside', root)).toBe(inside);
|
|
24
|
-
} finally {
|
|
25
|
-
rmSync(root, { recursive: true, force: true });
|
|
26
|
-
}
|
|
27
|
-
});
|
|
28
|
-
});
|
package/src/utils.ts
DELETED
|
@@ -1,203 +0,0 @@
|
|
|
1
|
-
import { isAbsolute, resolve, relative, parse as parsePath, sep } from 'path';
|
|
2
|
-
import { realpathSync, lstatSync, existsSync } from 'fs';
|
|
3
|
-
import { randomBytes } from 'crypto';
|
|
4
|
-
|
|
5
|
-
const DURATION_RE = /^(\d*\.?\d+)\s*(s|m|h|d)$/;
|
|
6
|
-
|
|
7
|
-
export function parseDuration(input: string): number {
|
|
8
|
-
const match = DURATION_RE.exec(input.trim());
|
|
9
|
-
if (!match) {
|
|
10
|
-
throw new Error(`Invalid duration format: "${input}". Expected format: <number>(s|m|h|d)`);
|
|
11
|
-
}
|
|
12
|
-
const value = parseFloat(match[1]);
|
|
13
|
-
const unit = match[2];
|
|
14
|
-
switch (unit) {
|
|
15
|
-
case 's':
|
|
16
|
-
return value * 1000;
|
|
17
|
-
case 'm':
|
|
18
|
-
return value * 60_000;
|
|
19
|
-
case 'h':
|
|
20
|
-
return value * 3_600_000;
|
|
21
|
-
case 'd':
|
|
22
|
-
return value * 86_400_000;
|
|
23
|
-
default:
|
|
24
|
-
throw new Error(`Unknown duration unit: "${unit}"`);
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function validatePath(filePath: string, projectRoot: string): string {
|
|
29
|
-
const resolved = resolve(projectRoot, filePath);
|
|
30
|
-
|
|
31
|
-
// D2: Cross-drive check (Windows) — path.relative('C:\\root', 'D:\\x') returns
|
|
32
|
-
// 'D:\\x' which does NOT start with '..', so a pure relative check would wrongly
|
|
33
|
-
// allow cross-drive paths. Reject them explicitly before any further comparison.
|
|
34
|
-
if (parsePath(projectRoot).root !== parsePath(resolved).root) {
|
|
35
|
-
throw new Error(
|
|
36
|
-
`Security: path "${filePath}" is on a different drive than the project root "${projectRoot}".`,
|
|
37
|
-
);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const rel = relative(projectRoot, resolved);
|
|
41
|
-
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
42
|
-
throw new Error(
|
|
43
|
-
`Security: path "${filePath}" escapes project root. ` +
|
|
44
|
-
`All file references must be within "${projectRoot}".`,
|
|
45
|
-
);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// D1: Resolve symlinks and re-validate so a symlink whose string path is
|
|
49
|
-
// inside the project root but whose target lies outside is rejected.
|
|
50
|
-
// Only resolve if the path exists on disk; at parse time the file may not
|
|
51
|
-
// yet exist (e.g. a future output path), so we skip realpath for absent paths.
|
|
52
|
-
if (existsSync(resolved)) {
|
|
53
|
-
// Reject the entry outright if it is itself a symlink — callers that want
|
|
54
|
-
// to allow symlinks within the tree can pass pre-resolved paths.
|
|
55
|
-
try {
|
|
56
|
-
const stat = lstatSync(resolved);
|
|
57
|
-
if (stat.isSymbolicLink()) {
|
|
58
|
-
throw new Error(
|
|
59
|
-
`Security: path "${filePath}" is a symbolic link. Symbolic links are not allowed within the project root.`,
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
} catch (err) {
|
|
63
|
-
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Also verify the real (fully resolved) path stays within the project root.
|
|
67
|
-
let real: string;
|
|
68
|
-
try {
|
|
69
|
-
real = realpathSync.native(resolved);
|
|
70
|
-
} catch {
|
|
71
|
-
real = resolved; // path vanished between existsSync and realpathSync — skip
|
|
72
|
-
}
|
|
73
|
-
const realRoot = (() => {
|
|
74
|
-
try {
|
|
75
|
-
return realpathSync.native(projectRoot);
|
|
76
|
-
} catch {
|
|
77
|
-
return projectRoot;
|
|
78
|
-
}
|
|
79
|
-
})();
|
|
80
|
-
if (parsePath(realRoot).root !== parsePath(real).root) {
|
|
81
|
-
throw new Error(
|
|
82
|
-
`Security: resolved path "${real}" is on a different drive than the project root "${realRoot}".`,
|
|
83
|
-
);
|
|
84
|
-
}
|
|
85
|
-
const realRel = relative(realRoot, real);
|
|
86
|
-
if (realRel === '..' || realRel.startsWith(`..${sep}`) || isAbsolute(realRel)) {
|
|
87
|
-
throw new Error(
|
|
88
|
-
`Security: path "${filePath}" resolves via symlink to "${real}" which escapes project root "${realRoot}".`,
|
|
89
|
-
);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
return resolved;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export function generateRunId(): string {
|
|
97
|
-
const ts = Date.now().toString(36);
|
|
98
|
-
const rand = randomBytes(6).toString('hex');
|
|
99
|
-
return `run_${ts}_${rand}`;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
export function truncateForName(text: string, maxLen = 40): string {
|
|
103
|
-
const first = text.split('\n')[0]!.trim();
|
|
104
|
-
// Guard: if the first line is empty (e.g. prompt is all whitespace/newlines),
|
|
105
|
-
// fall back to the raw text trimmed rather than silently producing an empty name.
|
|
106
|
-
if (!first) return text.trim().slice(0, maxLen) || '...';
|
|
107
|
-
return first.length > maxLen ? first.slice(0, maxLen) + '...' : first;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export function nowISO(): string {
|
|
111
|
-
return new Date().toISOString();
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// ═══ Platform-aware shell ═══
|
|
115
|
-
//
|
|
116
|
-
// Resolution order:
|
|
117
|
-
// 1. Env override: PIPELINE_SHELL="bash" or PIPELINE_SHELL="cmd" etc.
|
|
118
|
-
// 2. Windows: prefer sh (Git Bash / MSYS2) if on PATH, fall back to cmd.exe
|
|
119
|
-
// 3. Unix: sh
|
|
120
|
-
//
|
|
121
|
-
// Resolution is cached once on first call to avoid repeated PATH lookups.
|
|
122
|
-
|
|
123
|
-
const IS_WINDOWS = process.platform === 'win32';
|
|
124
|
-
|
|
125
|
-
type ShellKind = 'sh' | 'bash' | 'cmd' | 'powershell';
|
|
126
|
-
let resolvedShell: { kind: ShellKind; path: string } | null = null;
|
|
127
|
-
|
|
128
|
-
function detectShell(): { kind: ShellKind; path: string } {
|
|
129
|
-
// Env override takes precedence
|
|
130
|
-
const override = process.env.PIPELINE_SHELL;
|
|
131
|
-
if (override) {
|
|
132
|
-
const kind = override as ShellKind;
|
|
133
|
-
return { kind, path: override };
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
if (!IS_WINDOWS) {
|
|
137
|
-
return { kind: 'sh', path: 'sh' };
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// Windows: probe PATH for sh (bundled with Git for Windows / MSYS2)
|
|
141
|
-
const pathEnv = process.env.PATH ?? '';
|
|
142
|
-
const pathExt = (process.env.PATHEXT ?? '.EXE;.CMD;.BAT').split(';');
|
|
143
|
-
const dirs = pathEnv.split(';').filter(Boolean);
|
|
144
|
-
|
|
145
|
-
for (const dir of dirs) {
|
|
146
|
-
for (const ext of ['', ...pathExt]) {
|
|
147
|
-
const candidate = `${dir}\\sh${ext}`;
|
|
148
|
-
try {
|
|
149
|
-
if (Bun.file(candidate).size > 0) {
|
|
150
|
-
return { kind: 'sh', path: candidate };
|
|
151
|
-
}
|
|
152
|
-
} catch {
|
|
153
|
-
/* ignore */
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// Fallback: cmd.exe (always present on Windows)
|
|
159
|
-
const systemRoot = process.env.SystemRoot ?? 'C:\\Windows';
|
|
160
|
-
return { kind: 'cmd', path: `${systemRoot}\\System32\\cmd.exe` };
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function getShell(): { kind: ShellKind; path: string } {
|
|
164
|
-
if (!resolvedShell) resolvedShell = detectShell();
|
|
165
|
-
return resolvedShell;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
export function shellArgs(command: string): readonly string[] {
|
|
169
|
-
const sh = getShell();
|
|
170
|
-
if (sh.kind === 'cmd') {
|
|
171
|
-
return [sh.path, '/c', command];
|
|
172
|
-
}
|
|
173
|
-
if (sh.kind === 'powershell') {
|
|
174
|
-
return [sh.path, '-Command', command];
|
|
175
|
-
}
|
|
176
|
-
// sh or bash
|
|
177
|
-
return [sh.path, '-c', command];
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/** Quote a single argument for inclusion in a shell command string. */
|
|
181
|
-
function quoteArg(arg: string): string {
|
|
182
|
-
if (!/[\s"'\\<>|&;`$!^%]/.test(arg)) return arg;
|
|
183
|
-
if (IS_WINDOWS) {
|
|
184
|
-
// On Windows (cmd.exe), double-quote and escape embedded quotes + backslashes
|
|
185
|
-
return '"' + arg.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
|
|
186
|
-
}
|
|
187
|
-
// On Unix, use single quotes to prevent $variable expansion.
|
|
188
|
-
// Escape embedded single quotes via the '\'' idiom.
|
|
189
|
-
return "'" + arg.replace(/'/g, "'\\''") + "'";
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Convert an args array to shell-wrapped args suitable for Bun.spawn.
|
|
194
|
-
* Each arg is quoted as needed, then joined and passed through shellArgs.
|
|
195
|
-
*/
|
|
196
|
-
export function shellArgsFromArray(args: readonly string[]): readonly string[] {
|
|
197
|
-
return shellArgs(args.map(quoteArg).join(' '));
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// For tests: allow resetting the cached shell detection
|
|
201
|
-
export function _resetShellCache(): void {
|
|
202
|
-
resolvedShell = null;
|
|
203
|
-
}
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import { compileYamlContent } from './yaml-compiler';
|
|
3
|
-
import { validateRaw } from './validate-raw';
|
|
4
|
-
import type { RawPipelineConfig } from './types';
|
|
5
|
-
|
|
6
|
-
describe('validateRaw known plugin types', () => {
|
|
7
|
-
test('warns when pipeline, track, or prompt task references an unknown driver', () => {
|
|
8
|
-
const config: RawPipelineConfig = {
|
|
9
|
-
name: 'driver checks',
|
|
10
|
-
driver: 'missing-pipeline',
|
|
11
|
-
tracks: [
|
|
12
|
-
{
|
|
13
|
-
id: 'main',
|
|
14
|
-
name: 'Main',
|
|
15
|
-
driver: 'missing-track',
|
|
16
|
-
tasks: [
|
|
17
|
-
{
|
|
18
|
-
id: 'prompt',
|
|
19
|
-
name: 'Prompt',
|
|
20
|
-
prompt: 'hello',
|
|
21
|
-
driver: 'missing-task',
|
|
22
|
-
},
|
|
23
|
-
],
|
|
24
|
-
},
|
|
25
|
-
],
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
const diagnostics = validateRaw(config, { drivers: ['opencode'] });
|
|
29
|
-
expect(diagnostics.map((d) => d.message)).toEqual(
|
|
30
|
-
expect.arrayContaining([
|
|
31
|
-
'Unknown driver type "missing-pipeline"',
|
|
32
|
-
'Unknown driver type "missing-track"',
|
|
33
|
-
'Unknown driver type "missing-task"',
|
|
34
|
-
]),
|
|
35
|
-
);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
test('compileYamlContent includes unknown driver warnings from knownTypes.drivers', () => {
|
|
39
|
-
const result = compileYamlContent(
|
|
40
|
-
[
|
|
41
|
-
'pipeline:',
|
|
42
|
-
' name: Unknown Driver',
|
|
43
|
-
' driver: ghost',
|
|
44
|
-
' tracks:',
|
|
45
|
-
' - id: main',
|
|
46
|
-
' name: Main',
|
|
47
|
-
' tasks:',
|
|
48
|
-
' - id: prompt',
|
|
49
|
-
' name: Prompt',
|
|
50
|
-
' prompt: Hello',
|
|
51
|
-
'',
|
|
52
|
-
].join('\n'),
|
|
53
|
-
{ knownTypes: { drivers: ['opencode'] } },
|
|
54
|
-
);
|
|
55
|
-
|
|
56
|
-
expect(result.validation.warnings.map((w) => w.message)).toContain(
|
|
57
|
-
'Unknown driver type "ghost"',
|
|
58
|
-
);
|
|
59
|
-
});
|
|
60
|
-
});
|
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import type { RawPipelineConfig, RawTaskConfig } from './types';
|
|
3
|
-
import { validateRaw } from './validate-raw';
|
|
4
|
-
|
|
5
|
-
function commandTask(overrides: Partial<RawTaskConfig> & { id: string }): RawTaskConfig {
|
|
6
|
-
return { command: 'echo {{inputs.city}}', ...overrides };
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
function promptTask(overrides: Partial<RawTaskConfig> & { id: string }): RawTaskConfig {
|
|
10
|
-
return { prompt: 'hello {{inputs.city}}', ...overrides };
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function config(tasks: RawTaskConfig[]): RawPipelineConfig {
|
|
14
|
-
return {
|
|
15
|
-
name: 'p',
|
|
16
|
-
tracks: [{ id: 't', name: 'T', tasks }],
|
|
17
|
-
};
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function errorsFor(task: RawTaskConfig) {
|
|
21
|
-
return validateRaw(config([task]));
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
describe('validateRaw — ports migration', () => {
|
|
25
|
-
test('rejects ports with a migration message', () => {
|
|
26
|
-
const errors = errorsFor(
|
|
27
|
-
commandTask({
|
|
28
|
-
id: 'a',
|
|
29
|
-
ports: { inputs: [{ name: 'city', type: 'string' }] },
|
|
30
|
-
}),
|
|
31
|
-
);
|
|
32
|
-
expect(errors.some((e) => e.path === 'tracks[0].tasks[0].ports')).toBe(true);
|
|
33
|
-
expect(errors.some((e) => /replaced by typed inputs\/outputs/.test(e.message))).toBe(true);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
test('empty ports is still rejected', () => {
|
|
37
|
-
const errors = errorsFor(commandTask({ id: 'a', ports: {} }));
|
|
38
|
-
expect(errors.some((e) => /ports has been replaced/.test(e.message))).toBe(true);
|
|
39
|
-
});
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
describe('validateRaw — unified typed bindings', () => {
|
|
43
|
-
test('accepts typed command inputs and outputs', () => {
|
|
44
|
-
const errors = errorsFor(
|
|
45
|
-
commandTask({
|
|
46
|
-
id: 'a',
|
|
47
|
-
inputs: { city: { type: 'string', required: true } },
|
|
48
|
-
outputs: { temp: { type: 'number' } },
|
|
49
|
-
}),
|
|
50
|
-
);
|
|
51
|
-
expect(errors).toEqual([]);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
test('rejects invalid binding maps, names, type, and enum shape', () => {
|
|
55
|
-
const errors = errorsFor(
|
|
56
|
-
commandTask({
|
|
57
|
-
id: 'a',
|
|
58
|
-
command: 'echo {{inputs.city}}',
|
|
59
|
-
inputs: {
|
|
60
|
-
'bad-name': { value: 'x' },
|
|
61
|
-
city: { type: 'made-up' as never },
|
|
62
|
-
kind: { type: 'enum' },
|
|
63
|
-
},
|
|
64
|
-
outputs: { ok: 'bad' as never },
|
|
65
|
-
}),
|
|
66
|
-
);
|
|
67
|
-
const msgs = errors.map((e) => e.message);
|
|
68
|
-
expect(msgs.some((m) => /binding name "bad-name" is invalid/.test(m))).toBe(true);
|
|
69
|
-
expect(msgs.some((m) => /task\.inputs\.city\.type must be one of/.test(m))).toBe(true);
|
|
70
|
-
expect(msgs.some((m) => /task\.inputs\.kind\.enum must be a non-empty/.test(m))).toBe(true);
|
|
71
|
-
expect(msgs.some((m) => /task\.outputs\.ok must be an object/.test(m))).toBe(true);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
test('command placeholders must reference task.inputs', () => {
|
|
75
|
-
const errors = errorsFor(commandTask({ id: 'a', command: 'echo {{inputs.missing}}' }));
|
|
76
|
-
expect(errors.some((e) => e.message.includes('references "{{inputs.missing}}"'))).toBe(true);
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
test('fully-qualified input sources must reference direct dependencies', () => {
|
|
80
|
-
const errors = validateRaw(
|
|
81
|
-
config([
|
|
82
|
-
commandTask({ id: 'up', command: 'echo ok', outputs: { city: {} } }),
|
|
83
|
-
commandTask({
|
|
84
|
-
id: 'down',
|
|
85
|
-
command: 'echo {{inputs.city}}',
|
|
86
|
-
inputs: { city: { from: 't.up.outputs.city' } },
|
|
87
|
-
}),
|
|
88
|
-
]),
|
|
89
|
-
);
|
|
90
|
-
expect(errors.some((e) => /not a direct dependency/.test(e.message))).toBe(true);
|
|
91
|
-
});
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
describe('validateRaw — prompt inferred bindings', () => {
|
|
95
|
-
test('prompt placeholders can reference direct upstream command outputs', () => {
|
|
96
|
-
const errors = validateRaw(
|
|
97
|
-
config([
|
|
98
|
-
commandTask({ id: 'up', command: 'echo ok', outputs: { city: { type: 'string' } } }),
|
|
99
|
-
promptTask({ id: 'p', depends_on: ['up'], prompt: 'city={{inputs.city}}' }),
|
|
100
|
-
]),
|
|
101
|
-
);
|
|
102
|
-
expect(errors.some((e) => e.message.includes('references "{{inputs.city}}"'))).toBe(false);
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
test('two upstream command outputs with the same name are ambiguous for prompts', () => {
|
|
106
|
-
const errors = validateRaw(
|
|
107
|
-
config([
|
|
108
|
-
commandTask({ id: 'a', command: 'echo ok', outputs: { city: { type: 'string' } } }),
|
|
109
|
-
commandTask({ id: 'b', command: 'echo ok', outputs: { city: { type: 'string' } } }),
|
|
110
|
-
promptTask({ id: 'p', depends_on: ['a', 'b'], prompt: 'city={{inputs.city}}' }),
|
|
111
|
-
]),
|
|
112
|
-
);
|
|
113
|
-
expect(errors.some((e) => /cannot disambiguate/.test(e.message))).toBe(true);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
test('downstream commands with incompatible typed inputs conflict for prompt outputs', () => {
|
|
117
|
-
const errors = validateRaw(
|
|
118
|
-
config([
|
|
119
|
-
promptTask({ id: 'p', prompt: 'make date' }),
|
|
120
|
-
commandTask({
|
|
121
|
-
id: 'a',
|
|
122
|
-
depends_on: ['p'],
|
|
123
|
-
command: 'echo {{inputs.date}}',
|
|
124
|
-
inputs: { date: { type: 'string' } },
|
|
125
|
-
}),
|
|
126
|
-
commandTask({
|
|
127
|
-
id: 'b',
|
|
128
|
-
depends_on: ['p'],
|
|
129
|
-
command: 'echo {{inputs.date}}',
|
|
130
|
-
inputs: { date: { type: 'number' } },
|
|
131
|
-
}),
|
|
132
|
-
]),
|
|
133
|
-
);
|
|
134
|
-
expect(errors.some((e) => /disagree on the shape of inferred output "date"/.test(e.message))).toBe(true);
|
|
135
|
-
});
|
|
136
|
-
});
|