@arki/dot 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +36 -31
  2. package/dist/define-app.d.ts +58 -16
  3. package/dist/define-app.d.ts.map +1 -1
  4. package/dist/define-app.js +23 -13
  5. package/dist/define-app.js.map +1 -1
  6. package/dist/index.d.ts +7 -5
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +6 -4
  9. package/dist/index.js.map +1 -1
  10. package/dist/kernel/app-instance.d.ts +3 -3
  11. package/dist/kernel/app-instance.d.ts.map +1 -1
  12. package/dist/kernel/app-instance.js +265 -159
  13. package/dist/kernel/app-instance.js.map +1 -1
  14. package/dist/lifecycle.d.ts +11 -9
  15. package/dist/lifecycle.d.ts.map +1 -1
  16. package/dist/lifecycle.js +15 -9
  17. package/dist/lifecycle.js.map +1 -1
  18. package/dist/pip-contract.d.ts +254 -149
  19. package/dist/pip-contract.d.ts.map +1 -1
  20. package/dist/pip-contract.js +185 -41
  21. package/dist/pip-contract.js.map +1 -1
  22. package/dist/pip.d.ts +9 -12
  23. package/dist/pip.d.ts.map +1 -1
  24. package/dist/pip.js +8 -11
  25. package/dist/pip.js.map +1 -1
  26. package/dist/test-harness.d.ts +13 -10
  27. package/dist/test-harness.d.ts.map +1 -1
  28. package/dist/test-harness.js +12 -12
  29. package/dist/test-harness.js.map +1 -1
  30. package/package.json +10 -4
  31. package/src/cli/discover.ts +223 -0
  32. package/src/cli/error-codes.ts +74 -0
  33. package/src/cli/files.ts +120 -0
  34. package/src/cli/index.ts +539 -0
  35. package/src/cli/json.ts +49 -0
  36. package/src/cli/new.ts +420 -0
  37. package/src/cli/observability-probe.ts +51 -0
  38. package/src/cli/render-doctor.ts +199 -0
  39. package/src/cli/render-explain.ts +161 -0
  40. package/src/define-app.ts +310 -0
  41. package/src/diagnostics.ts +91 -0
  42. package/src/index.ts +89 -0
  43. package/src/kernel/app-instance.ts +1341 -0
  44. package/src/kernel/otel.ts +265 -0
  45. package/src/lifecycle-observer.ts +100 -0
  46. package/src/lifecycle.ts +121 -0
  47. package/src/manifest.ts +94 -0
  48. package/src/pip-contract.ts +477 -0
  49. package/src/pip.ts +84 -0
  50. package/src/test-harness.ts +72 -0
  51. package/src/timeline.ts +137 -0
  52. package/templates/app-minimal/AGENTS.md.tmpl +2 -2
@@ -0,0 +1,223 @@
1
+ /**
2
+ * App-file discovery + dynamic import for the DOT CLI.
3
+ *
4
+ * The CLI inspects a user's DOT app at the command line. Apps live in source
5
+ * files like `dot.config.ts` or `src/app.ts`; this module resolves the right
6
+ * file, imports it, unwraps the default export, and returns either a
7
+ * `DotApp` or `DotAppBuilder`.
8
+ *
9
+ * The CLI commands (`explain`, `doctor`) then decide whether to call
10
+ * `.configure()` / `.boot()` on the result.
11
+ */
12
+
13
+ import { stat } from 'node:fs/promises';
14
+ import { isAbsolute, resolve } from 'node:path';
15
+ import { pathToFileURL } from 'node:url';
16
+
17
+ import { createDebugLogger } from '@arki/log/debug';
18
+
19
+ import type { DotApp, DotAppBuilder, DotAppConfigured } from '../define-app.js';
20
+ import { DotCliError, DotCliErrorCode } from './error-codes.js';
21
+
22
+ const debugDiscover = createDebugLogger('arki:dot:cli:discover');
23
+
24
+ /**
25
+ * Default file names probed at cwd, in priority order.
26
+ * Each entry is tried with each of the supported file extensions below.
27
+ */
28
+ const DEFAULT_APP_PATHS: readonly string[] = ['dot.config', 'src/app', 'app'];
29
+
30
+ /**
31
+ * Supported file extensions. `.ts` is preferred because the CLI runs under
32
+ * Bun, which executes TypeScript natively. `.js` and `.mjs` are accepted for
33
+ * apps that prefer JS sources or pre-compiled output.
34
+ */
35
+ const SUPPORTED_EXTENSIONS: readonly string[] = ['.ts', '.mts', '.mjs', '.js'];
36
+
37
+ export type DiscoveredApp = DotApp<Record<string, unknown>> | DotAppBuilder<Record<string, unknown>> | DotAppConfigured<Record<string, unknown>>;
38
+
39
+ export type DiscoveryOptions = {
40
+ /** Explicit app file path. When set, takes precedence over the search list. */
41
+ appPath?: string;
42
+ /** Working directory for discovery. Defaults to `process.cwd()`. */
43
+ cwd?: string;
44
+ };
45
+
46
+ export type DiscoveryResult = {
47
+ /** Absolute path of the resolved app file. */
48
+ filePath: string;
49
+ /** Discovered app (builder, configured, or booted). */
50
+ app: DiscoveredApp;
51
+ };
52
+
53
+ async function fileExists(filePath: string): Promise<boolean> {
54
+ try {
55
+ const result = await stat(filePath);
56
+ return result.isFile();
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Resolve which app file to load.
64
+ *
65
+ * If `appPath` is set, return its absolute path (without checking existence —
66
+ * the caller's `import` will throw with a clearer error if missing).
67
+ * Otherwise, walk the default file list and return the first that exists.
68
+ *
69
+ * Returns `null` when nothing was found.
70
+ */
71
+ export async function resolveAppFile(options: DiscoveryOptions = {}): Promise<string | null> {
72
+ const cwd = options.cwd ?? process.cwd();
73
+
74
+ if (options.appPath) {
75
+ const absolute = isAbsolute(options.appPath) ? options.appPath : resolve(cwd, options.appPath);
76
+ debugDiscover('explicit app path: %s', absolute);
77
+ return absolute;
78
+ }
79
+
80
+ for (const base of DEFAULT_APP_PATHS) {
81
+ for (const ext of SUPPORTED_EXTENSIONS) {
82
+ const candidate = resolve(cwd, `${base}${ext}`);
83
+ if (await fileExists(candidate)) {
84
+ debugDiscover('discovered app file: %s', candidate);
85
+ return candidate;
86
+ }
87
+ }
88
+ }
89
+
90
+ debugDiscover('no app file found at %s', cwd);
91
+ return null;
92
+ }
93
+
94
+ /**
95
+ * Heuristic guard: detect whether the default export looks like a `DotApp` or
96
+ * `DotAppBuilder` / `DotAppConfigured`. We avoid `instanceof` because the
97
+ * surface types are structural; instead we duck-type the key methods.
98
+ */
99
+ function isDotAppBuilder(value: unknown): value is DotAppBuilder<Record<string, unknown>> {
100
+ if (typeof value !== 'object' || value === null) return false;
101
+ const v = value as Record<string, unknown>;
102
+ return typeof v.use === 'function' && typeof v.configure === 'function' && typeof v.boot === 'function';
103
+ }
104
+
105
+ function isDotAppConfigured(value: unknown): value is DotAppConfigured<Record<string, unknown>> {
106
+ if (typeof value !== 'object' || value === null) return false;
107
+ const v = value as Record<string, unknown>;
108
+ return (
109
+ typeof v.name === 'string' &&
110
+ typeof v.boot === 'function' &&
111
+ 'manifest' in v &&
112
+ 'diagnostics' in v &&
113
+ typeof v.use !== 'function'
114
+ );
115
+ }
116
+
117
+ function isDotApp(value: unknown): value is DotApp<Record<string, unknown>> {
118
+ if (typeof value !== 'object' || value === null) return false;
119
+ const v = value as Record<string, unknown>;
120
+ return (
121
+ typeof v.name === 'string' &&
122
+ typeof v.dispose === 'function' &&
123
+ typeof v.start === 'function' &&
124
+ typeof v.stop === 'function' &&
125
+ 'manifest' in v &&
126
+ 'diagnostics' in v &&
127
+ 'services' in v
128
+ );
129
+ }
130
+
131
+ function unwrapDefaultExport(mod: unknown): unknown {
132
+ if (typeof mod !== 'object' || mod === null) return mod;
133
+ const record = mod as Record<string, unknown>;
134
+ if ('default' in record) return record.default;
135
+ return mod;
136
+ }
137
+
138
+ /**
139
+ * Dynamic-import the app file and unwrap its default export into a
140
+ * `DotApp` / `DotAppBuilder`. Functions returning either are awaited.
141
+ *
142
+ * Throws `DotCliError` on any failure path.
143
+ */
144
+ export async function loadAppFromFile(filePath: string): Promise<DiscoveredApp> {
145
+ let mod: unknown;
146
+ try {
147
+ // Use `pathToFileURL` so dynamic import accepts absolute paths on Windows.
148
+ mod = await import(pathToFileURL(filePath).href);
149
+ } catch (err) {
150
+ throw new DotCliError({
151
+ code: DotCliErrorCode.AppImportFailed,
152
+ message: `Failed to import app file at ${filePath}: ${err instanceof Error ? err.message : String(err)}`,
153
+ remediation:
154
+ 'Verify the file path is correct and that the module loads without errors. Run the file directly with `bun <path>` to reproduce.',
155
+ metadata: { filePath },
156
+ cause: err,
157
+ });
158
+ }
159
+
160
+ let candidate = unwrapDefaultExport(mod);
161
+
162
+ // If the default export is a function, call it (it may return the
163
+ // builder/app, or a Promise of one). This supports lazy/async setup.
164
+ if (typeof candidate === 'function') {
165
+ try {
166
+ const fn = candidate as () => unknown;
167
+ const result = fn();
168
+ candidate = result instanceof Promise ? await result : result;
169
+ } catch (err) {
170
+ throw new DotCliError({
171
+ code: DotCliErrorCode.AppImportFailed,
172
+ message: `Default export function in ${filePath} threw: ${err instanceof Error ? err.message : String(err)}`,
173
+ remediation: 'Make the default-exported factory return a DotApp or DotAppBuilder. Check for setup errors.',
174
+ metadata: { filePath },
175
+ cause: err,
176
+ });
177
+ }
178
+ }
179
+
180
+ if (isDotApp(candidate) || isDotAppBuilder(candidate) || isDotAppConfigured(candidate)) {
181
+ debugDiscover('loaded app from %s', filePath);
182
+ return candidate;
183
+ }
184
+
185
+ throw new DotCliError({
186
+ code: DotCliErrorCode.AppInvalidExport,
187
+ message: `Default export from ${filePath} is not a DotApp or DotAppBuilder.`,
188
+ remediation:
189
+ 'Export a DotApp or DotAppBuilder as the default. Example: `export default defineApp("my-app").use(pip);`',
190
+ metadata: { filePath },
191
+ });
192
+ }
193
+
194
+ /**
195
+ * Combined: resolve the file and load the app. Throws `DotCliError` when no
196
+ * file was found or the import/export shape is invalid.
197
+ */
198
+ export async function discoverApp(options: DiscoveryOptions = {}): Promise<DiscoveryResult> {
199
+ const filePath = await resolveAppFile(options);
200
+
201
+ if (!filePath) {
202
+ const cwd = options.cwd ?? process.cwd();
203
+ throw new DotCliError({
204
+ code: DotCliErrorCode.AppNotFound,
205
+ message: `No DOT app file found in ${cwd}.`,
206
+ remediation:
207
+ 'Create `dot.config.ts` exporting your app (e.g. `export default defineApp("my-app").use(pip)`), or pass `--app <path>` to point at an existing file.',
208
+ metadata: { cwd, searched: DEFAULT_APP_PATHS },
209
+ });
210
+ }
211
+
212
+ const app = await loadAppFromFile(filePath);
213
+ return { filePath, app };
214
+ }
215
+
216
+ /**
217
+ * Type guards re-exported for test and renderer consumers.
218
+ */
219
+ export const guards = {
220
+ isDotApp,
221
+ isDotAppBuilder,
222
+ isDotAppConfigured,
223
+ };
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Stable error codes used by the DOT CLI.
3
+ *
4
+ * Every code starts with `DOT_CLI_E` so it can be visually distinguished from
5
+ * lifecycle errors (`DOT_LIFECYCLE_E*`). Codes are part of the public contract
6
+ * — agents grep on them to branch behaviour. Do not renumber.
7
+ */
8
+
9
+ export const DotCliErrorCode = {
10
+ /** No app file could be discovered at the cwd and `--app` was not provided. */
11
+ AppNotFound: 'DOT_CLI_E001',
12
+ /** App file was located but its default export is not a DotApp/DotAppBuilder. */
13
+ AppInvalidExport: 'DOT_CLI_E002',
14
+ /** Importing the app file threw an exception. */
15
+ AppImportFailed: 'DOT_CLI_E003',
16
+ /** Unknown CLI command. */
17
+ UnknownCommand: 'DOT_CLI_E004',
18
+ /** CLI args were malformed. */
19
+ InvalidArgs: 'DOT_CLI_E005',
20
+ /** Configure or boot threw while preparing the app for inspection. */
21
+ AppLifecycleFailed: 'DOT_CLI_E006',
22
+ /** `dot doctor --observability` ran but no OTel SDK is registered. */
23
+ ObservabilityNoSdk: 'DOT_CLI_E007',
24
+ } as const;
25
+
26
+ export type DotCliErrorCodeValue = (typeof DotCliErrorCode)[keyof typeof DotCliErrorCode];
27
+
28
+ const DOCS_BASE = 'https://docs.arki.dev/dot/cli';
29
+
30
+ /**
31
+ * Anchors on the CLI docs page. Kept here so both the renderers and tests can
32
+ * reference the same URL without drift.
33
+ */
34
+ export const DotCliDocsAnchor: Record<DotCliErrorCodeValue, string> = {
35
+ [DotCliErrorCode.AppNotFound]: 'app-not-found',
36
+ [DotCliErrorCode.AppInvalidExport]: 'app-invalid-export',
37
+ [DotCliErrorCode.AppImportFailed]: 'app-import-failed',
38
+ [DotCliErrorCode.UnknownCommand]: 'unknown-command',
39
+ [DotCliErrorCode.InvalidArgs]: 'invalid-args',
40
+ [DotCliErrorCode.AppLifecycleFailed]: 'app-lifecycle-failed',
41
+ [DotCliErrorCode.ObservabilityNoSdk]: 'observability-no-sdk',
42
+ };
43
+
44
+ export function dotCliDocsUrl(code: DotCliErrorCodeValue): string {
45
+ return `${DOCS_BASE}#${DotCliDocsAnchor[code]}`;
46
+ }
47
+
48
+ /**
49
+ * Structured CLI error. Carries enough metadata to be rendered as a JSON
50
+ * envelope or a human-readable diagnostic line.
51
+ */
52
+ export class DotCliError extends Error {
53
+ readonly code: DotCliErrorCodeValue;
54
+ readonly remediation: string;
55
+ readonly docsUrl: string;
56
+ readonly metadata?: Record<string, unknown>;
57
+ override readonly cause?: unknown;
58
+
59
+ constructor(args: {
60
+ code: DotCliErrorCodeValue;
61
+ message: string;
62
+ remediation: string;
63
+ metadata?: Record<string, unknown>;
64
+ cause?: unknown;
65
+ }) {
66
+ super(args.message);
67
+ this.name = 'DotCliError';
68
+ this.code = args.code;
69
+ this.remediation = args.remediation;
70
+ this.docsUrl = dotCliDocsUrl(args.code);
71
+ this.metadata = args.metadata;
72
+ this.cause = args.cause;
73
+ }
74
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Filesystem helpers for the `dot new` scaffold.
3
+ *
4
+ * Kept deliberately small and dependency-free so the scaffolding code path
5
+ * stays auditable. The scaffold first builds an in-memory list of
6
+ * {@link FileOperation} entries describing every file it WOULD write, then
7
+ * either prints them (`--dry-run`) or commits them to disk.
8
+ */
9
+
10
+ import { createHash } from 'node:crypto';
11
+ import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
12
+ import path from 'node:path';
13
+
14
+ /**
15
+ * Action the generator will take for a particular file.
16
+ *
17
+ * - `create` — file does not yet exist at the target path.
18
+ * - `overwrite` — file exists; the generator will replace it (only when
19
+ * `--force` was passed).
20
+ * - `skip` — file exists and the generator refuses to overwrite without
21
+ * `--force`. Surfaced in dry-run output and converted to a failure on
22
+ * real runs.
23
+ */
24
+ export type FileOperationAction = 'create' | 'overwrite' | 'skip';
25
+
26
+ /**
27
+ * Description of a single file the scaffold plans to emit.
28
+ *
29
+ * `path` is always relative to the target directory so the envelope is
30
+ * portable across machines. `contentHash` is sha256 of the rendered bytes
31
+ * — agents can use it to confirm the file we wrote matches the file we
32
+ * planned.
33
+ */
34
+ export type FileOperation = {
35
+ readonly path: string;
36
+ readonly action: FileOperationAction;
37
+ readonly contentHash: string;
38
+ readonly contentBytes: number;
39
+ readonly reason: string;
40
+ /**
41
+ * Rendered file contents. Internal to the generator — NOT emitted in the
42
+ * JSON envelope. Kept on the in-memory plan so a non-dry-run can write
43
+ * the same bytes that produced `contentHash`.
44
+ */
45
+ readonly content: string;
46
+ };
47
+
48
+ /**
49
+ * Walk a template directory and return the relative paths of every file.
50
+ *
51
+ * Subdirectories are descended into; symlinks and non-regular files are
52
+ * ignored. Paths are returned without a leading separator and are sorted
53
+ * lexicographically for deterministic output.
54
+ */
55
+ export async function collectTemplateFiles(root: string): Promise<string[]> {
56
+ const out: string[] = [];
57
+
58
+ async function walk(dir: string): Promise<void> {
59
+ const entries = await readdir(dir, { withFileTypes: true });
60
+ for (const entry of entries) {
61
+ const full = path.join(dir, entry.name);
62
+ if (entry.isDirectory()) {
63
+ await walk(full);
64
+ } else if (entry.isFile()) {
65
+ out.push(path.relative(root, full));
66
+ }
67
+ }
68
+ }
69
+
70
+ await walk(root);
71
+ out.sort();
72
+ return out;
73
+ }
74
+
75
+ /** Compute the sha256 hex digest of a string payload (utf-8 encoded). */
76
+ export function sha256(content: string): string {
77
+ return createHash('sha256').update(content, 'utf8').digest('hex');
78
+ }
79
+
80
+ /** Byte length of a utf-8 string. Wrapper exists so the call site is obvious. */
81
+ export function utf8ByteLength(content: string): number {
82
+ return Buffer.byteLength(content, 'utf8');
83
+ }
84
+
85
+ /** Return `true` when a regular file exists at the given path. */
86
+ export async function fileExists(filePath: string): Promise<boolean> {
87
+ try {
88
+ const result = await stat(filePath);
89
+ return result.isFile();
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+
95
+ /** Return `true` when a directory exists and contains at least one entry. */
96
+ export async function directoryIsNonEmpty(dirPath: string): Promise<boolean> {
97
+ try {
98
+ const entries = await readdir(dirPath);
99
+ return entries.length > 0;
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Write a single file, creating parent directories as needed.
107
+ */
108
+ export async function writeFileEnsuringDir(filePath: string, content: string): Promise<void> {
109
+ await mkdir(path.dirname(filePath), { recursive: true });
110
+ await writeFile(filePath, content, 'utf8');
111
+ }
112
+
113
+ /**
114
+ * Read a template file from disk. Trivially thin wrapper around
115
+ * `fs.readFile`, but exists so the scaffold imports stay symmetric with
116
+ * its helpers.
117
+ */
118
+ export async function readTemplate(filePath: string): Promise<string> {
119
+ return readFile(filePath, 'utf8');
120
+ }