@ductape/cli 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/API_PARITY.md +58 -0
  2. package/CHANGELOG.md +36 -0
  3. package/README.md +89 -0
  4. package/dist/commands/apps-import.d.ts +9 -0
  5. package/dist/commands/apps-import.js +29 -0
  6. package/dist/commands/apps.d.ts +10 -0
  7. package/dist/commands/apps.js +68 -0
  8. package/dist/commands/cloud.d.ts +7 -0
  9. package/dist/commands/cloud.js +42 -0
  10. package/dist/commands/completion.d.ts +1 -0
  11. package/dist/commands/completion.js +113 -0
  12. package/dist/commands/db.d.ts +5 -0
  13. package/dist/commands/db.js +55 -0
  14. package/dist/commands/generate.d.ts +14 -0
  15. package/dist/commands/generate.js +52 -0
  16. package/dist/commands/graph.d.ts +4 -0
  17. package/dist/commands/graph.js +44 -0
  18. package/dist/commands/init.d.ts +7 -0
  19. package/dist/commands/init.js +28 -0
  20. package/dist/commands/install.d.ts +1 -0
  21. package/dist/commands/install.js +42 -0
  22. package/dist/commands/link.d.ts +7 -0
  23. package/dist/commands/link.js +61 -0
  24. package/dist/commands/login.d.ts +9 -0
  25. package/dist/commands/login.js +103 -0
  26. package/dist/commands/logout.d.ts +1 -0
  27. package/dist/commands/logout.js +6 -0
  28. package/dist/commands/platform.d.ts +7 -0
  29. package/dist/commands/platform.js +99 -0
  30. package/dist/commands/products.d.ts +14 -0
  31. package/dist/commands/products.js +74 -0
  32. package/dist/commands/profiles.d.ts +2 -0
  33. package/dist/commands/profiles.js +18 -0
  34. package/dist/commands/resources.d.ts +7 -0
  35. package/dist/commands/resources.js +61 -0
  36. package/dist/commands/secrets.d.ts +6 -0
  37. package/dist/commands/secrets.js +38 -0
  38. package/dist/commands/unlink.d.ts +3 -0
  39. package/dist/commands/unlink.js +14 -0
  40. package/dist/commands/whoami.d.ts +1 -0
  41. package/dist/commands/whoami.js +22 -0
  42. package/dist/commands/workspaces.d.ts +16 -0
  43. package/dist/commands/workspaces.js +72 -0
  44. package/dist/index.d.ts +2 -0
  45. package/dist/index.js +283 -0
  46. package/dist/lib/api-client.d.ts +18 -0
  47. package/dist/lib/api-client.js +65 -0
  48. package/dist/lib/app-import.d.ts +15 -0
  49. package/dist/lib/app-import.js +63 -0
  50. package/dist/lib/config.d.ts +63 -0
  51. package/dist/lib/config.js +155 -0
  52. package/dist/lib/context-store.d.ts +17 -0
  53. package/dist/lib/context-store.js +43 -0
  54. package/dist/lib/db-methods.d.ts +3 -0
  55. package/dist/lib/db-methods.js +55 -0
  56. package/dist/lib/encryption.d.ts +1 -0
  57. package/dist/lib/encryption.js +11 -0
  58. package/dist/lib/forms/enums.d.ts +11 -0
  59. package/dist/lib/forms/enums.js +42 -0
  60. package/dist/lib/forms/index.d.ts +2 -0
  61. package/dist/lib/forms/index.js +2 -0
  62. package/dist/lib/forms/prompt.d.ts +11 -0
  63. package/dist/lib/forms/prompt.js +203 -0
  64. package/dist/lib/forms/schemas.d.ts +9 -0
  65. package/dist/lib/forms/schemas.js +253 -0
  66. package/dist/lib/forms/types.d.ts +18 -0
  67. package/dist/lib/forms/types.js +1 -0
  68. package/dist/lib/http.d.ts +10 -0
  69. package/dist/lib/http.js +51 -0
  70. package/dist/lib/integrations.d.ts +20 -0
  71. package/dist/lib/integrations.js +20 -0
  72. package/dist/lib/interactive-opts.d.ts +6 -0
  73. package/dist/lib/interactive-opts.js +6 -0
  74. package/dist/lib/output.d.ts +3 -0
  75. package/dist/lib/output.js +22 -0
  76. package/dist/lib/platform-api.d.ts +19 -0
  77. package/dist/lib/platform-api.js +123 -0
  78. package/dist/lib/proxy/context.d.ts +16 -0
  79. package/dist/lib/proxy/context.js +73 -0
  80. package/dist/lib/proxy/db-proxy.d.ts +13 -0
  81. package/dist/lib/proxy/db-proxy.js +28 -0
  82. package/dist/lib/proxy/graph-proxy.d.ts +19 -0
  83. package/dist/lib/proxy/graph-proxy.js +43 -0
  84. package/dist/lib/proxy/sdk-proxy.d.ts +14 -0
  85. package/dist/lib/proxy/sdk-proxy.js +22 -0
  86. package/dist/lib/read-body.d.ts +11 -0
  87. package/dist/lib/read-body.js +38 -0
  88. package/dist/lib/resources.d.ts +12 -0
  89. package/dist/lib/resources.js +147 -0
  90. package/dist/lib/snippets.d.ts +6 -0
  91. package/dist/lib/snippets.js +146 -0
  92. package/dist/lib/templates.d.ts +3 -0
  93. package/dist/lib/templates.js +74 -0
  94. package/dist/lib/workspace-context.d.ts +12 -0
  95. package/dist/lib/workspace-context.js +25 -0
  96. package/dist/lib/workspaces.d.ts +45 -0
  97. package/dist/lib/workspaces.js +219 -0
  98. package/package.json +37 -0
  99. package/src/commands/apps-import.ts +43 -0
  100. package/src/commands/apps.ts +92 -0
  101. package/src/commands/cloud.ts +50 -0
  102. package/src/commands/completion.ts +119 -0
  103. package/src/commands/db.ts +63 -0
  104. package/src/commands/generate.ts +74 -0
  105. package/src/commands/graph.ts +51 -0
  106. package/src/commands/init.ts +39 -0
  107. package/src/commands/install.ts +48 -0
  108. package/src/commands/link.ts +84 -0
  109. package/src/commands/login.ts +145 -0
  110. package/src/commands/logout.ts +7 -0
  111. package/src/commands/platform.ts +123 -0
  112. package/src/commands/products.ts +104 -0
  113. package/src/commands/profiles.ts +23 -0
  114. package/src/commands/resources.ts +85 -0
  115. package/src/commands/secrets.ts +46 -0
  116. package/src/commands/unlink.ts +15 -0
  117. package/src/commands/whoami.ts +27 -0
  118. package/src/commands/workspaces.ts +98 -0
  119. package/src/index.ts +381 -0
  120. package/src/lib/api-client.ts +88 -0
  121. package/src/lib/app-import.ts +82 -0
  122. package/src/lib/config.ts +225 -0
  123. package/src/lib/context-store.ts +77 -0
  124. package/src/lib/db-methods.ts +56 -0
  125. package/src/lib/encryption.ts +12 -0
  126. package/src/lib/forms/enums.ts +52 -0
  127. package/src/lib/forms/index.ts +8 -0
  128. package/src/lib/forms/prompt.ts +209 -0
  129. package/src/lib/forms/schemas.ts +288 -0
  130. package/src/lib/forms/types.ts +27 -0
  131. package/src/lib/http.ts +70 -0
  132. package/src/lib/integrations.ts +47 -0
  133. package/src/lib/interactive-opts.ts +13 -0
  134. package/src/lib/output.ts +24 -0
  135. package/src/lib/platform-api.ts +178 -0
  136. package/src/lib/proxy/context.ts +94 -0
  137. package/src/lib/proxy/db-proxy.ts +40 -0
  138. package/src/lib/proxy/graph-proxy.ts +64 -0
  139. package/src/lib/proxy/sdk-proxy.ts +58 -0
  140. package/src/lib/read-body.ts +57 -0
  141. package/src/lib/resources.ts +156 -0
  142. package/src/lib/snippets.ts +165 -0
  143. package/src/lib/templates.ts +78 -0
  144. package/src/lib/workspace-context.ts +43 -0
  145. package/src/lib/workspaces.ts +283 -0
  146. package/templates/dotnet/Program.cs +2 -0
  147. package/templates/go/main.go +8 -0
  148. package/templates/python/ductape_example.py +16 -0
  149. package/templates/typescript/ductape.example.ts +18 -0
  150. package/test/encryption.test.ts +20 -0
  151. package/test/forms.test.ts +25 -0
  152. package/test/platform-api.test.ts +19 -0
  153. package/test/resources.test.ts +23 -0
  154. package/test/workspace-resolve.test.ts +29 -0
  155. package/tsconfig.json +15 -0
@@ -0,0 +1,165 @@
1
+ const ALLOWED_SNIPPET_METHODS: Record<string, string[]> = {
2
+ action: ['run', 'dispatch', 'execute'],
3
+ workflow: ['run', 'dispatch', 'execute'],
4
+ database: ['dispatch', 'find', 'insert', 'update', 'delete', 'query', 'upsert', 'count', 'aggregate'],
5
+ graph: ['dispatch', 'find', 'insert', 'update', 'delete', 'query', 'execute'],
6
+ vector: ['query', 'find', 'findSimilar', 'upsert', 'insert', 'delete', 'dispatch'],
7
+ storage: ['dispatch', 'upload', 'download', 'remove', 'listFiles', 'getSignedUrl', 'stats'],
8
+ notification: ['dispatch', 'send', 'email.send', 'push.send', 'sms.send', 'callback.send'],
9
+ messaging: ['dispatch', 'send', 'publish', 'produce', 'consume'],
10
+ broker: ['dispatch', 'send', 'publish', 'produce', 'consume'],
11
+ quota: ['run', 'dispatch', 'check', 'consume'],
12
+ fallback: ['run', 'dispatch'],
13
+ healthcheck: ['run', 'check', 'status'],
14
+ health: ['run', 'check', 'status'],
15
+ session: ['start', 'verify', 'refresh', 'revoke', 'listActive'],
16
+ cache: ['get', 'set', 'clear', 'clearAll', 'dispatch', 'fetchValues'],
17
+ };
18
+
19
+ export function ensureSupportedSnippetOperation(operationFamily: string, method: string): void {
20
+ const family = operationFamily.toLowerCase();
21
+ const allowed = ALLOWED_SNIPPET_METHODS[family] ?? [];
22
+ if (!allowed.includes(method)) {
23
+ throw new Error(
24
+ `Unsupported snippet operation "${family}.${method}". Allowed: ${allowed.join(', ') || '(none)'}`,
25
+ );
26
+ }
27
+ }
28
+
29
+ function resolveSdkCallPath(operationFamily: string, method: string): string {
30
+ const family = operationFamily.toLowerCase();
31
+ const m = method.toLowerCase();
32
+
33
+ if (family === 'action') return `actions.${m}`;
34
+ if (family === 'workflow') return `workflows.${m}`;
35
+ if (family === 'database') return m === 'dispatch' ? 'databases.dispatch' : `databases.${m}`;
36
+ if (family === 'graph') return m === 'dispatch' ? 'graph.dispatch' : `graph.${m}`;
37
+ if (family === 'vector') return `vector.${m}`;
38
+ if (family === 'storage') return m === 'dispatch' ? 'storage.dispatch' : `storage.${m}`;
39
+ if (family === 'notification') return m === 'dispatch' ? 'notifications.dispatch' : `notifications.${m}`;
40
+ if (family === 'messaging' || family === 'broker') {
41
+ if (m === 'dispatch') return 'messageBrokers.dispatch';
42
+ if (m === 'send' || m === 'publish' || m === 'produce') return 'messageBrokers.produce';
43
+ return `messageBrokers.${m}`;
44
+ }
45
+ if (family === 'quota') return `quotas.${m}`;
46
+ if (family === 'fallback') return `fallback.${m}`;
47
+ if (family === 'healthcheck' || family === 'health') return `health.${m}`;
48
+ if (family === 'session') return `sessions.${m}`;
49
+ if (family === 'cache') return (m === 'dispatch' ? 'caches.dispatch' : `caches.${m}`);
50
+ return `${family}.${m}`;
51
+ }
52
+
53
+ function buildInvocationArgs(payload: Record<string, unknown>): Record<string, unknown> {
54
+ const input = (payload.input as Record<string, unknown>) ?? {};
55
+ return {
56
+ product: payload.product_tag,
57
+ env: payload.env,
58
+ ...input,
59
+ ...(payload.session ? { session: payload.session } : {}),
60
+ ...(payload.cache ? { cache: payload.cache } : {}),
61
+ };
62
+ }
63
+
64
+ function prettyJson(v: unknown): string {
65
+ return JSON.stringify(v ?? {}, null, 2);
66
+ }
67
+
68
+ export function buildTypeScriptSnippet(
69
+ payload: Record<string, unknown>,
70
+ operationFamily: string,
71
+ method: string,
72
+ ): string {
73
+ const callPath = resolveSdkCallPath(operationFamily, method);
74
+ const args = buildInvocationArgs(payload);
75
+ return `import Ductape from '@ductape/sdk';
76
+
77
+ const ductape = new Ductape({
78
+ publishableKey: process.env.DUCTAPE_PUBLISHABLE_KEY!,
79
+ product: process.env.DUCTAPE_PRODUCT!,
80
+ env: process.env.DUCTAPE_ENV!,
81
+ });
82
+
83
+ async function main() {
84
+ const result = await ductape.${callPath}(${prettyJson(args)});
85
+ console.log(JSON.stringify(result, null, 2));
86
+ }
87
+
88
+ main().catch(console.error);
89
+ `;
90
+ }
91
+
92
+ export function buildPythonSnippet(
93
+ payload: Record<string, unknown>,
94
+ operationFamily: string,
95
+ method: string,
96
+ ): string {
97
+ const callPath = resolveSdkCallPath(operationFamily, method).replace(/\./g, '_');
98
+ const args = buildInvocationArgs(payload);
99
+ return `import os
100
+ import json
101
+ from ductape import Ductape
102
+
103
+ ductape = Ductape(
104
+ publishable_key=os.environ["DUCTAPE_PUBLISHABLE_KEY"],
105
+ product=os.environ["DUCTAPE_PRODUCT"],
106
+ env=os.environ["DUCTAPE_ENV"],
107
+ )
108
+
109
+ def main():
110
+ args = ${prettyJson(args)}
111
+ result = ductape.${callPath}(args)
112
+ print(json.dumps(result, indent=2))
113
+
114
+ if __name__ == "__main__":
115
+ main()
116
+ `;
117
+ }
118
+
119
+ export function buildGoSnippet(
120
+ payload: Record<string, unknown>,
121
+ operationFamily: string,
122
+ method: string,
123
+ ): string {
124
+ const callPath = resolveSdkCallPath(operationFamily, method);
125
+ return `package main
126
+
127
+ // go get github.com/ductape/ductape/sdk/go
128
+ // See https://docs.ductape.app for ${operationFamily}.${method}
129
+ // Payload preview:
130
+ // ${prettyJson(payload).split('\n').join('\n// ')}
131
+ func main() {
132
+ // ductape.${callPath}(...)
133
+ }
134
+ `;
135
+ }
136
+
137
+ export function buildDotnetSnippet(
138
+ payload: Record<string, unknown>,
139
+ operationFamily: string,
140
+ method: string,
141
+ ): string {
142
+ const callPath = resolveSdkCallPath(operationFamily, method);
143
+ return `// dotnet add package Ductape.Sdk
144
+ // Operation: ${operationFamily}.${method} -> ${callPath}
145
+ // Payload: ${JSON.stringify(payload)}
146
+ `;
147
+ }
148
+
149
+ export function buildSnippet(
150
+ language: 'typescript' | 'python' | 'go' | 'dotnet',
151
+ payload: Record<string, unknown>,
152
+ operationFamily: string,
153
+ method: string,
154
+ ): string {
155
+ switch (language) {
156
+ case 'python':
157
+ return buildPythonSnippet(payload, operationFamily, method);
158
+ case 'go':
159
+ return buildGoSnippet(payload, operationFamily, method);
160
+ case 'dotnet':
161
+ return buildDotnetSnippet(payload, operationFamily, method);
162
+ default:
163
+ return buildTypeScriptSnippet(payload, operationFamily, method);
164
+ }
165
+ }
@@ -0,0 +1,78 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+ const TEMPLATES_ROOT = path.resolve(__dirname, '../../templates');
7
+
8
+ export type InitLanguage = 'typescript' | 'python' | 'go' | 'dotnet' | 'unknown';
9
+
10
+ export function detectLanguage(dir: string): InitLanguage {
11
+ if (fs.existsSync(path.join(dir, 'package.json'))) return 'typescript';
12
+ if (fs.existsSync(path.join(dir, 'pyproject.toml')) || fs.existsSync(path.join(dir, 'requirements.txt'))) {
13
+ return 'python';
14
+ }
15
+ if (fs.existsSync(path.join(dir, 'go.mod'))) return 'go';
16
+ try {
17
+ if (fs.readdirSync(dir).some((f) => f.endsWith('.csproj'))) return 'dotnet';
18
+ } catch {
19
+ /* ignore */
20
+ }
21
+ return 'unknown';
22
+ }
23
+
24
+ function copyTemplate(lang: InitLanguage, targetDir: string): void {
25
+ const map: Record<InitLanguage, { src: string; dest: string } | null> = {
26
+ typescript: { src: 'typescript/ductape.example.ts', dest: 'ductape.example.ts' },
27
+ python: { src: 'python/ductape_example.py', dest: 'ductape_example.py' },
28
+ go: { src: 'go/main.go', dest: 'ductape_example.go' },
29
+ dotnet: { src: 'dotnet/Program.cs', dest: 'DuctapeExample.cs' },
30
+ unknown: null,
31
+ };
32
+ const entry = map[lang];
33
+ if (!entry) return;
34
+ const srcPath = path.join(TEMPLATES_ROOT, entry.src);
35
+ const destPath = path.join(targetDir, entry.dest);
36
+ if (!fs.existsSync(destPath) && fs.existsSync(srcPath)) {
37
+ fs.copyFileSync(srcPath, destPath);
38
+ }
39
+ }
40
+
41
+ export function writeInitArtifacts(dir: string, lang: InitLanguage): void {
42
+ const envContent = `# Ductape — credentials from Workbench → SDK Access
43
+ DUCTAPE_ACCESS_KEY=
44
+ # Passed to new Ductape({ accessKey, product, env }) — constructor is the only place for product/env defaults
45
+ DUCTAPE_PRODUCT=
46
+ DUCTAPE_ENV=dev
47
+ `;
48
+
49
+ const envPath = path.join(dir, '.env.ductape.example');
50
+ if (!fs.existsSync(envPath)) fs.writeFileSync(envPath, envContent);
51
+
52
+ const ductapeDir = path.join(dir, '.ductape');
53
+ fs.mkdirSync(ductapeDir, { recursive: true });
54
+
55
+ const readme = `# Ductape project
56
+
57
+ Linked via \`ductape link\`. Manage resources with:
58
+
59
+ \`\`\`bash
60
+ ductape resources storage list
61
+ ductape resources databases create -f database.json
62
+ \`\`\`
63
+
64
+ Docs: https://docs.ductape.app/docs/cli/
65
+ `;
66
+ const readmePath = path.join(ductapeDir, 'README.md');
67
+ if (!fs.existsSync(readmePath)) fs.writeFileSync(readmePath, readme);
68
+
69
+ copyTemplate(lang, dir);
70
+
71
+ const gitignorePath = path.join(dir, '.gitignore');
72
+ if (fs.existsSync(gitignorePath)) {
73
+ const gi = fs.readFileSync(gitignorePath, 'utf8');
74
+ if (!gi.includes('.env') && !gi.includes('.env.local')) {
75
+ fs.appendFileSync(gitignorePath, '\n.env\n.env.local\n');
76
+ }
77
+ }
78
+ }
@@ -0,0 +1,43 @@
1
+ import {
2
+ getActiveProfileName,
3
+ getActiveWorkspaceId,
4
+ getApiUrl,
5
+ loadCredentials,
6
+ type Credentials,
7
+ } from './config.js';
8
+ import { requireCredentials } from './proxy/context.js';
9
+
10
+ export interface WorkspaceContext {
11
+ credentials: Credentials;
12
+ workspaceId: string;
13
+ apiUrl: string;
14
+ profile: string;
15
+ }
16
+
17
+ export function requireWorkspaceContext(opts?: { profile?: string }): WorkspaceContext {
18
+ const credentials = requireCredentials();
19
+ const workspaceId = getActiveWorkspaceId();
20
+ if (!workspaceId) {
21
+ throw new Error('No active workspace. Run: ductape login or ductape workspaces use');
22
+ }
23
+ const profile = opts?.profile ?? getActiveProfileName(null);
24
+ return {
25
+ credentials,
26
+ workspaceId,
27
+ apiUrl: getApiUrl(profile),
28
+ profile,
29
+ };
30
+ }
31
+
32
+ /** Query params required by integrations/apps REST endpoints (matches Workbench). */
33
+ export function workspaceAuthQuery(
34
+ ctx: WorkspaceContext,
35
+ extra?: Record<string, string>,
36
+ ): Record<string, string> {
37
+ return {
38
+ user_id: ctx.credentials.user_id,
39
+ public_key: ctx.credentials.public_key,
40
+ workspace_id: ctx.workspaceId,
41
+ ...extra,
42
+ };
43
+ }
@@ -0,0 +1,283 @@
1
+ import select from '@inquirer/select';
2
+ import { ApiClient } from './api-client.js';
3
+ import {
4
+ findProjectConfig,
5
+ getApiUrl,
6
+ loadCredentials,
7
+ loadGlobalConfig,
8
+ saveCredentials,
9
+ saveGlobalConfig,
10
+ saveProjectConfig,
11
+ type Credentials,
12
+ } from './config.js';
13
+ import { success } from './output.js';
14
+
15
+ /** Workspace entry from login or workspaces API (field names vary). */
16
+ export interface WorkspaceSummary {
17
+ workspace_id?: string;
18
+ workspace_name?: string;
19
+ _id?: string;
20
+ name?: string;
21
+ tag?: string;
22
+ default?: boolean;
23
+ accepted?: boolean;
24
+ }
25
+
26
+ export function workspaceId(w: WorkspaceSummary): string {
27
+ const id = w.workspace_id ?? w._id;
28
+ if (!id) throw new Error('Workspace entry missing id');
29
+ return id;
30
+ }
31
+
32
+ export function workspaceLabel(w: WorkspaceSummary): string {
33
+ return w.workspace_name ?? w.name ?? w.tag ?? workspaceId(w);
34
+ }
35
+
36
+ /** Human-readable workspace tag for .ductape/config.json (slug of name, or explicit tag). */
37
+ export function workspaceTag(w: WorkspaceSummary): string {
38
+ if (w.tag?.trim()) return w.tag.trim();
39
+ return slugifyTag(workspaceLabel(w));
40
+ }
41
+
42
+ export function slugifyTag(value: string): string {
43
+ return value
44
+ .trim()
45
+ .toLowerCase()
46
+ .replace(/[^a-z0-9]+/g, '-')
47
+ .replace(/^-+|-+$/g, '');
48
+ }
49
+
50
+ export function normalizeWorkspaces(raw: unknown[]): WorkspaceSummary[] {
51
+ return raw
52
+ .filter((w): w is Record<string, unknown> => Boolean(w) && typeof w === 'object')
53
+ .map((w) => ({
54
+ workspace_id: String(w.workspace_id ?? w._id ?? ''),
55
+ workspace_name: (w.workspace_name ?? w.name ?? w.tag) as string | undefined,
56
+ _id: w._id as string | undefined,
57
+ name: w.name as string | undefined,
58
+ tag: w.tag as string | undefined,
59
+ default: Boolean(w.default),
60
+ accepted: w.accepted as boolean | undefined,
61
+ }))
62
+ .filter((w) => w.workspace_id.length > 0);
63
+ }
64
+
65
+ export function getActiveWorkspace(): { id: string; name?: string; tag?: string } | null {
66
+ const global = loadGlobalConfig();
67
+ if (global.active_workspace_id) {
68
+ return {
69
+ id: global.active_workspace_id,
70
+ name: global.active_workspace_name,
71
+ tag: global.active_workspace_tag,
72
+ };
73
+ }
74
+ return null;
75
+ }
76
+
77
+ export function setActiveWorkspace(id: string, name?: string, tag?: string): void {
78
+ const global = loadGlobalConfig();
79
+ global.active_workspace_id = id;
80
+ global.active_workspace_name = name;
81
+ if (tag) global.active_workspace_tag = tag;
82
+ saveGlobalConfig(global);
83
+ }
84
+
85
+ export function clearActiveWorkspace(): void {
86
+ const global = loadGlobalConfig();
87
+ delete global.active_workspace_id;
88
+ delete global.active_workspace_name;
89
+ delete global.active_workspace_tag;
90
+ saveGlobalConfig(global);
91
+ }
92
+
93
+ export async function fetchWorkspaces(profile?: string): Promise<WorkspaceSummary[]> {
94
+ const creds = loadCredentials();
95
+ if (!creds) throw new Error('Not logged in. Run: ductape login');
96
+
97
+ const cached = normalizeWorkspaces(creds.workspaces ?? []);
98
+ const api = ApiClient.forProfile(profile, creds);
99
+ const apiUrl = getApiUrl(profile);
100
+
101
+ try {
102
+ const res = await fetch(
103
+ `${apiUrl}/workspaces/v1/fetch/${creds.user_id}?user_id=${encodeURIComponent(creds.user_id)}&public_key=${encodeURIComponent(creds.public_key)}`,
104
+ { headers: { Authorization: `Bearer ${creds.auth_token}` } },
105
+ );
106
+ const body = await res.json();
107
+ if (!res.ok) throw new Error(body.message ?? `Failed to list workspaces (${res.status})`);
108
+ const data = body.data ?? body;
109
+ const list = Array.isArray(data) ? data : (data?.result ?? data?.workspaces ?? []);
110
+ const normalized = normalizeWorkspaces(list as unknown[]);
111
+ if (normalized.length > 0) {
112
+ saveCredentials({ ...creds, workspaces: normalized as Credentials['workspaces'] });
113
+ return normalized;
114
+ }
115
+ } catch {
116
+ // fall through to cache / api client
117
+ }
118
+
119
+ try {
120
+ const list = await api.get<WorkspaceSummary[]>(
121
+ `/workspaces/v1/fetch/${creds.user_id}?user_id=${encodeURIComponent(creds.user_id)}&public_key=${encodeURIComponent(creds.public_key)}`,
122
+ );
123
+ const normalized = normalizeWorkspaces(list as unknown[]);
124
+ if (normalized.length > 0) {
125
+ saveCredentials({ ...creds, workspaces: normalized as Credentials['workspaces'] });
126
+ return normalized;
127
+ }
128
+ } catch {
129
+ // ignore
130
+ }
131
+
132
+ return cached;
133
+ }
134
+
135
+ export function findWorkspace(
136
+ workspaces: WorkspaceSummary[],
137
+ selector: string,
138
+ ): WorkspaceSummary | undefined {
139
+ const trimmed = selector.trim();
140
+ const idx = Number(trimmed);
141
+ if (!Number.isNaN(idx) && workspaces[idx]) return workspaces[idx];
142
+
143
+ return workspaces.find(
144
+ (w) =>
145
+ workspaceId(w) === trimmed ||
146
+ workspaceLabel(w).toLowerCase() === trimmed.toLowerCase() ||
147
+ w.tag?.toLowerCase() === trimmed.toLowerCase(),
148
+ );
149
+ }
150
+
151
+ export function printWorkspaceTable(
152
+ workspaces: WorkspaceSummary[],
153
+ activeId?: string | null,
154
+ ): void {
155
+ if (workspaces.length === 0) {
156
+ console.log('No workspaces found for this account.');
157
+ return;
158
+ }
159
+ workspaces.forEach((w, i) => {
160
+ const id = workspaceId(w);
161
+ const label = workspaceLabel(w);
162
+ const markers: string[] = [];
163
+ if (activeId && id === activeId) markers.push('active');
164
+ if (w.default) markers.push('default');
165
+ const suffix = markers.length ? ` (${markers.join(', ')})` : '';
166
+ console.log(` [${i}] ${label}${suffix}`);
167
+ console.log(` ${id}`);
168
+ });
169
+ }
170
+
171
+ export async function promptWorkspaceSelection(
172
+ workspaces: WorkspaceSummary[],
173
+ opts?: { message?: string },
174
+ ): Promise<WorkspaceSummary> {
175
+ if (workspaces.length === 0) {
176
+ throw new Error('No workspaces available. Create one in the Workbench first.');
177
+ }
178
+
179
+ if (workspaces.length === 1) {
180
+ const only = workspaces[0];
181
+ setActiveWorkspace(workspaceId(only), workspaceLabel(only), workspaceTag(only));
182
+ return only;
183
+ }
184
+
185
+ const active = getActiveWorkspace();
186
+ const activeId = active?.id ?? null;
187
+ const defaultWs =
188
+ workspaces.find((w) => active && workspaceId(w) === active.id) ??
189
+ workspaces.find((w) => w.default) ??
190
+ workspaces[0];
191
+ const defaultIdx = workspaces.indexOf(defaultWs);
192
+
193
+ // Non-interactive (CI, pipes): keep default without prompting.
194
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
195
+ return defaultWs;
196
+ }
197
+
198
+ const choiceIndex = await select<number>({
199
+ message: opts?.message ?? 'Select a workspace',
200
+ pageSize: Math.min(12, workspaces.length),
201
+ default: defaultIdx >= 0 ? defaultIdx : 0,
202
+ choices: workspaces.map((w, i) => {
203
+ const id = workspaceId(w);
204
+ const label = workspaceLabel(w);
205
+ const markers: string[] = [];
206
+ if (activeId && id === activeId) markers.push('active');
207
+ if (w.default) markers.push('default');
208
+ const suffix = markers.length ? ` · ${markers.join(', ')}` : '';
209
+ return {
210
+ name: label + suffix,
211
+ value: i,
212
+ description: id,
213
+ };
214
+ }),
215
+ });
216
+
217
+ return workspaces[choiceIndex];
218
+ }
219
+
220
+ export async function applyActiveWorkspace(
221
+ workspace: WorkspaceSummary,
222
+ opts?: { syncProject?: boolean; dir?: string },
223
+ ): Promise<void> {
224
+ const id = workspaceId(workspace);
225
+ const name = workspaceLabel(workspace);
226
+ const tag = workspaceTag(workspace);
227
+ setActiveWorkspace(id, name, tag);
228
+
229
+ if (opts?.syncProject !== false) {
230
+ const found = findProjectConfig(opts?.dir);
231
+ if (found) {
232
+ saveProjectConfig(found.dir, {
233
+ ...found.config,
234
+ workspace_tag: tag,
235
+ workspace_id: id,
236
+ });
237
+ }
238
+ }
239
+ }
240
+
241
+ export async function selectAndApplyWorkspace(opts?: {
242
+ profile?: string;
243
+ selector?: string;
244
+ skipPrompt?: boolean;
245
+ syncProject?: boolean;
246
+ dir?: string;
247
+ }): Promise<WorkspaceSummary> {
248
+ const workspaces = await fetchWorkspaces(opts?.profile);
249
+
250
+ let chosen: WorkspaceSummary;
251
+ if (opts?.selector) {
252
+ const match = findWorkspace(workspaces, opts.selector);
253
+ if (!match) throw new Error(`Workspace not found: ${opts.selector}`);
254
+ chosen = match;
255
+ } else if (opts?.skipPrompt) {
256
+ const active = getActiveWorkspace();
257
+ const match = active ? findWorkspace(workspaces, active.id) : undefined;
258
+ chosen = match ?? workspaces.find((w) => w.default) ?? workspaces[0];
259
+ if (!chosen) throw new Error('No workspace to select');
260
+ } else {
261
+ chosen = await promptWorkspaceSelection(workspaces);
262
+ }
263
+
264
+ await applyActiveWorkspace(chosen, { syncProject: opts?.syncProject, dir: opts?.dir });
265
+ return chosen;
266
+ }
267
+
268
+ export async function finalizeLoginWorkspace(opts?: {
269
+ profile?: string;
270
+ workspace?: string;
271
+ skipPrompt?: boolean;
272
+ }): Promise<void> {
273
+ const chosen = await selectAndApplyWorkspace({
274
+ profile: opts?.profile,
275
+ selector: opts?.workspace,
276
+ skipPrompt: opts?.skipPrompt,
277
+ syncProject: true,
278
+ });
279
+ const workspaces = await fetchWorkspaces(opts?.profile);
280
+ const hint =
281
+ workspaces.length > 1 ? '\nSwitch anytime: ductape workspaces use' : '';
282
+ success(`Active workspace: ${workspaceLabel(chosen)} (${workspaceId(chosen)})${hint}`);
283
+ }
@@ -0,0 +1,2 @@
1
+ // dotnet add package Ductape.Sdk
2
+ Console.WriteLine("Ductape client ready");
@@ -0,0 +1,8 @@
1
+ package main
2
+
3
+ import "fmt"
4
+
5
+ // go get github.com/ductape/ductape/sdk/go
6
+ func main() {
7
+ fmt.Println("Ductape client ready")
8
+ }
@@ -0,0 +1,16 @@
1
+ """Ductape client example."""
2
+ import os
3
+
4
+ # from ductape import Ductape
5
+ #
6
+ # client = Ductape(
7
+ # publishable_key=os.environ["DUCTAPE_PUBLISHABLE_KEY"],
8
+ # product=os.environ["DUCTAPE_PRODUCT"],
9
+ # env=os.environ.get("DUCTAPE_ENV", "dev"),
10
+ # )
11
+
12
+ def main():
13
+ print("Ductape client ready")
14
+
15
+ if __name__ == "__main__":
16
+ main()
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Ductape SDK example — product/env defaults are set on the constructor only.
3
+ * CLI: `ductape login` + `ductape link` (see .ductape/config.json).
4
+ */
5
+ import Ductape from '@ductape/sdk';
6
+
7
+ const ductape = new Ductape({
8
+ accessKey: process.env.DUCTAPE_ACCESS_KEY!,
9
+ product: process.env.DUCTAPE_PRODUCT!,
10
+ env: process.env.DUCTAPE_ENV ?? 'dev',
11
+ });
12
+
13
+ async function main() {
14
+ // await ductape.databases.connect({ database: 'main-db' });
15
+ console.log('Ductape client ready');
16
+ }
17
+
18
+ main().catch(console.error);
@@ -0,0 +1,20 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import CryptoJS from 'crypto-js';
4
+ import { encryptProxyPayload } from '../src/lib/encryption.js';
5
+
6
+ describe('encryptProxyPayload', () => {
7
+ it('produces iv:ciphertext format compatible with backend', () => {
8
+ const publicKey = 'test-public-key-12345';
9
+ const data = { module: 'storage', method: 'list', params: ['my-product'], user_id: 'u1' };
10
+ const encrypted = encryptProxyPayload(data, publicKey);
11
+ assert.match(encrypted, /^[0-9a-f]+:/i);
12
+
13
+ const [ivHex, cipherText] = encrypted.split(':');
14
+ const iv = CryptoJS.enc.Hex.parse(ivHex);
15
+ const hashedKey = CryptoJS.SHA256(publicKey);
16
+ const decrypted = CryptoJS.AES.decrypt(cipherText, hashedKey, { iv });
17
+ const json = JSON.parse(decrypted.toString(CryptoJS.enc.Utf8));
18
+ assert.deepEqual(json, data);
19
+ });
20
+ });
@@ -0,0 +1,25 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { getFormSchema, schemaKeyForEntity, schemaKeyForResource } from '../src/lib/forms/schemas.js';
4
+
5
+ describe('forms schemas', () => {
6
+ it('resolves product and component keys', () => {
7
+ assert.equal(schemaKeyForEntity('products', 'create'), 'products:create');
8
+ assert.equal(schemaKeyForResource('storage', 'create'), 'storage:create');
9
+ assert.equal(schemaKeyForResource('databases', 'connect'), 'databases:connect');
10
+ });
11
+
12
+ it('storage create includes enum type in envs', () => {
13
+ const schema = getFormSchema('storage:create');
14
+ assert.ok(schema);
15
+ const envs = schema!.fields.find((f) => f.kind === 'envs');
16
+ assert.equal(envs?.envsVariant, 'storage');
17
+ });
18
+
19
+ it('databases create has database type enum', () => {
20
+ const schema = getFormSchema('databases:create');
21
+ const typeField = schema!.fields.find((f) => f.key === 'type');
22
+ assert.equal(typeField?.kind, 'enum');
23
+ assert.ok(typeField?.choices?.includes('postgresql'));
24
+ });
25
+ });
@@ -0,0 +1,19 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ // Test unwrap helpers indirectly via exported patterns — use small inline copies for unit scope
5
+ function unwrapList<T>(result: unknown): T[] {
6
+ if (Array.isArray(result)) return result;
7
+ if (result && typeof result === 'object' && Array.isArray((result as { data?: T[] }).data)) {
8
+ return (result as { data: T[] }).data;
9
+ }
10
+ return [];
11
+ }
12
+
13
+ describe('platform-api helpers', () => {
14
+ it('unwraps array responses', () => {
15
+ assert.deepEqual(unwrapList([{ tag: 'a' }]), [{ tag: 'a' }]);
16
+ assert.deepEqual(unwrapList({ data: [{ tag: 'b' }] }), [{ tag: 'b' }]);
17
+ assert.deepEqual(unwrapList({}), []);
18
+ });
19
+ });