@ductape/cli 0.3.22 → 0.3.24
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/CHANGELOG.md +5 -0
- package/README.md +2 -0
- package/dist/commands/features-sync.d.ts +3 -0
- package/dist/commands/features-sync.js +21 -2
- package/dist/commands/login.js +62 -8
- package/dist/commands/resources.js +1 -1
- package/dist/index.js +10 -2
- package/dist/lib/proxy/sdk-proxy.d.ts +1 -1
- package/dist/lib/resources.js +18 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
- Browser OAuth now completes through a validated loopback callback and logs the CLI in automatically.
|
|
6
|
+
- Feature sync now establishes the official catalogue-only lifecycle with `DUCTAPE_SYNC_MODE=1` and exposes filtered ownership through `DUCTAPE_FEATURE_FILTER` before project bootstrap.
|
|
7
|
+
|
|
3
8
|
## 0.3.0 - 2026-07-26
|
|
4
9
|
|
|
5
10
|
- Added AI-led migration planning with new-codebase defaults and immutable original E2E gates.
|
package/README.md
CHANGED
|
@@ -48,6 +48,8 @@ Do not use `sudo npm link`; it mixes root-owned globals with your normal user.
|
|
|
48
48
|
```bash
|
|
49
49
|
ductape install
|
|
50
50
|
ductape login # uses `cloud` profile (https://api.ductape.app)
|
|
51
|
+
ductape login --browser google # browser OAuth; returns to the CLI automatically
|
|
52
|
+
ductape login --browser github
|
|
51
53
|
# Local platform (UI + API on your machine):
|
|
52
54
|
ductape profiles use local
|
|
53
55
|
ductape start
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
export interface FeaturesSyncOpts {
|
|
2
2
|
filter?: string;
|
|
3
3
|
}
|
|
4
|
+
export declare const DUCTAPE_CATALOGUE_SYNC_ENV = "DUCTAPE_SYNC_MODE";
|
|
5
|
+
export declare const DUCTAPE_FEATURE_FILTER_ENV = "DUCTAPE_FEATURE_FILTER";
|
|
6
|
+
export declare function buildFeaturesSyncEnvironment(filter?: string, base?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
4
7
|
/**
|
|
5
8
|
* Persists code-first Ductape Features to the live product. Unlike `ductape db migrate` (which
|
|
6
9
|
* executes declarative migration files directly), a Feature's handler is real application code
|
|
@@ -3,6 +3,16 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { findProjectConfig } from '../lib/config.js';
|
|
5
5
|
import { fail } from '../lib/output.js';
|
|
6
|
+
export const DUCTAPE_CATALOGUE_SYNC_ENV = 'DUCTAPE_SYNC_MODE';
|
|
7
|
+
export const DUCTAPE_FEATURE_FILTER_ENV = 'DUCTAPE_FEATURE_FILTER';
|
|
8
|
+
export function buildFeaturesSyncEnvironment(filter, base = process.env) {
|
|
9
|
+
const env = { ...base, [DUCTAPE_CATALOGUE_SYNC_ENV]: '1' };
|
|
10
|
+
if (filter)
|
|
11
|
+
env[DUCTAPE_FEATURE_FILTER_ENV] = filter;
|
|
12
|
+
else
|
|
13
|
+
delete env[DUCTAPE_FEATURE_FILTER_ENV];
|
|
14
|
+
return env;
|
|
15
|
+
}
|
|
6
16
|
const CONVENTION_HINT = `
|
|
7
17
|
Ductape convention for code-first Features:
|
|
8
18
|
1. Define every Feature under ductape/features/ (e.g. ductape/features/src/my-feature.ts),
|
|
@@ -13,6 +23,11 @@ Ductape convention for code-first Features:
|
|
|
13
23
|
construct real dependencies (no HTTP listener) and calls every registerXFeature(...) once.
|
|
14
24
|
4. Run it explicitly with \`ductape features sync\` — never automatically on app boot.
|
|
15
25
|
|
|
26
|
+
The CLI sets DUCTAPE_SYNC_MODE=1 for the child process. Ductape framework integrations use this
|
|
27
|
+
official catalogue-only signal to skip serving-time consumers and provider readiness. Project
|
|
28
|
+
readiness hooks should do the same. When a filter is supplied, it is also available as
|
|
29
|
+
DUCTAPE_FEATURE_FILTER and must be applied before booting unrelated service modules.
|
|
30
|
+
|
|
16
31
|
This mirrors \`ductape db migrate\`: Features are defined in source, but persisted to the live
|
|
17
32
|
product via an explicit command, so a slow or unreachable Ductape API can never block your app
|
|
18
33
|
from starting.
|
|
@@ -46,8 +61,12 @@ export async function runFeaturesSync(opts) {
|
|
|
46
61
|
const npmArgs = ['run', 'features:sync'];
|
|
47
62
|
if (opts.filter)
|
|
48
63
|
npmArgs.push('--', opts.filter);
|
|
49
|
-
console.log(`Running "npm ${npmArgs.join(' ')}" in ${dir} ...\n`);
|
|
50
|
-
const result = spawnSync('npm', npmArgs, {
|
|
64
|
+
console.log(`Running "npm ${npmArgs.join(' ')}" in ${dir} (catalogue sync mode) ...\n`);
|
|
65
|
+
const result = spawnSync('npm', npmArgs, {
|
|
66
|
+
cwd: dir,
|
|
67
|
+
stdio: 'inherit',
|
|
68
|
+
env: buildFeaturesSyncEnvironment(opts.filter),
|
|
69
|
+
});
|
|
51
70
|
if (result.status !== 0) {
|
|
52
71
|
fail(`features:sync exited with code ${result.status ?? 1}.`);
|
|
53
72
|
}
|
package/dist/commands/login.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import inputPrompt from '@inquirer/input';
|
|
2
2
|
import passwordPrompt from '@inquirer/password';
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
|
+
import { createServer } from 'node:http';
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
4
6
|
import { getApiUrl, getActiveProfileName, saveCredentials } from '../lib/config.js';
|
|
5
7
|
import { parseJsonResponse } from '../lib/http.js';
|
|
6
8
|
import { finalizeLoginWorkspace } from '../lib/workspaces.js';
|
|
@@ -38,6 +40,58 @@ function openBrowser(url) {
|
|
|
38
40
|
: 'xdg-open';
|
|
39
41
|
spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref();
|
|
40
42
|
}
|
|
43
|
+
async function browserOAuthLogin(apiUrl, provider) {
|
|
44
|
+
const state = randomBytes(24).toString('base64url');
|
|
45
|
+
let timeout;
|
|
46
|
+
const tokenPromise = new Promise((resolve, reject) => {
|
|
47
|
+
const server = createServer((req, res) => {
|
|
48
|
+
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
|
49
|
+
if (url.pathname !== '/callback') {
|
|
50
|
+
res.writeHead(404).end('Not found');
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const token = url.searchParams.get('token');
|
|
54
|
+
const returnedState = url.searchParams.get('state');
|
|
55
|
+
const error = url.searchParams.get('error');
|
|
56
|
+
const ok = !error && Boolean(token) && returnedState === state;
|
|
57
|
+
res.writeHead(ok ? 200 : 400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
58
|
+
res.end(ok
|
|
59
|
+
? '<!doctype html><title>Ductape CLI</title><p>Login complete. You can close this window.</p>'
|
|
60
|
+
: '<!doctype html><title>Ductape CLI</title><p>Login failed. Return to your terminal.</p>');
|
|
61
|
+
server.close();
|
|
62
|
+
if (timeout)
|
|
63
|
+
clearTimeout(timeout);
|
|
64
|
+
if (error)
|
|
65
|
+
reject(new Error(`OAuth login failed: ${error}`));
|
|
66
|
+
else if (!token || returnedState !== state)
|
|
67
|
+
reject(new Error('OAuth callback validation failed'));
|
|
68
|
+
else
|
|
69
|
+
resolve(token);
|
|
70
|
+
});
|
|
71
|
+
server.on('error', reject);
|
|
72
|
+
server.listen(0, '127.0.0.1', () => {
|
|
73
|
+
const address = server.address();
|
|
74
|
+
if (!address || typeof address === 'string') {
|
|
75
|
+
server.close();
|
|
76
|
+
reject(new Error('Could not start OAuth callback listener'));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const callback = `http://127.0.0.1:${address.port}/callback`;
|
|
80
|
+
const authUrl = new URL(`${apiUrl}/users/v1/auth/${provider}`);
|
|
81
|
+
authUrl.searchParams.set('cli_redirect', callback);
|
|
82
|
+
authUrl.searchParams.set('cli_state', state);
|
|
83
|
+
console.log(`Opening ${provider === 'github' ? 'GitHub' : 'Google'} in your browser…`);
|
|
84
|
+
console.log(`If the browser does not open, visit:\n ${authUrl.toString()}\n`);
|
|
85
|
+
openBrowser(authUrl.toString());
|
|
86
|
+
});
|
|
87
|
+
timeout = setTimeout(() => {
|
|
88
|
+
server.close();
|
|
89
|
+
reject(new Error('OAuth login timed out after 5 minutes'));
|
|
90
|
+
}, 5 * 60 * 1000);
|
|
91
|
+
timeout.unref();
|
|
92
|
+
});
|
|
93
|
+
return exchangeOAuthToken(apiUrl, await tokenPromise);
|
|
94
|
+
}
|
|
41
95
|
export async function runLogin(opts) {
|
|
42
96
|
const profileName = opts.profile ?? getActiveProfileName();
|
|
43
97
|
const apiUrl = getApiUrl(opts.profile);
|
|
@@ -53,14 +107,14 @@ export async function runLogin(opts) {
|
|
|
53
107
|
return;
|
|
54
108
|
}
|
|
55
109
|
if (opts.browser) {
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
110
|
+
const user = await browserOAuthLogin(apiUrl, opts.browser);
|
|
111
|
+
saveFromResult(user);
|
|
112
|
+
success(`Logged in as ${user.email} (OAuth)`);
|
|
113
|
+
await finalizeLoginWorkspace({
|
|
114
|
+
profile: opts.profile,
|
|
115
|
+
workspace: opts.workspace,
|
|
116
|
+
skipPrompt: opts.skipWorkspaceSelect,
|
|
117
|
+
});
|
|
64
118
|
return;
|
|
65
119
|
}
|
|
66
120
|
let email = opts.email;
|
|
@@ -291,7 +291,7 @@ export async function runResourceCrud(typeName, verb, opts, extraArgs) {
|
|
|
291
291
|
}
|
|
292
292
|
}
|
|
293
293
|
const method = proxyMethodForVerb(crud);
|
|
294
|
-
const proxyMethod = module === 'models' && method === 'list' ? 'fetchAll' : method;
|
|
294
|
+
const proxyMethod = (module === 'models' || module === 'feature') && method === 'list' ? 'fetchAll' : method;
|
|
295
295
|
const interactive = toInteractiveOpts(opts);
|
|
296
296
|
let tag = opts.tag ?? extraArgs[0];
|
|
297
297
|
if (!tag && ['get', 'update', 'delete'].includes(crud) && isInteractive(interactive)) {
|
package/dist/index.js
CHANGED
|
@@ -74,7 +74,7 @@ program
|
|
|
74
74
|
.option('-e, --email <email>')
|
|
75
75
|
.option('-p, --password <password>')
|
|
76
76
|
.option('--token <token>', 'OAuth callback token from browser redirect URL')
|
|
77
|
-
.option('--browser [provider]', '
|
|
77
|
+
.option('--browser [provider]', 'Log in through browser OAuth (google|github)')
|
|
78
78
|
.option('--profile <name>', 'API profile', 'cloud')
|
|
79
79
|
.option('-w, --workspace <id>', 'Workspace index, name, or id (skips interactive picker)')
|
|
80
80
|
.option('--skip-workspace-select', 'Keep prior active workspace or default without prompting')
|
|
@@ -83,7 +83,15 @@ program
|
|
|
83
83
|
password: opts.password,
|
|
84
84
|
profile: opts.profile,
|
|
85
85
|
token: opts.token,
|
|
86
|
-
browser: opts.browser ===
|
|
86
|
+
browser: opts.browser === undefined
|
|
87
|
+
? undefined
|
|
88
|
+
: opts.browser === true
|
|
89
|
+
? 'google'
|
|
90
|
+
: opts.browser === 'google' || opts.browser === 'github'
|
|
91
|
+
? opts.browser
|
|
92
|
+
: (() => {
|
|
93
|
+
throw new Error('--browser provider must be google or github');
|
|
94
|
+
})(),
|
|
87
95
|
workspace: opts.workspace,
|
|
88
96
|
skipWorkspaceSelect: Boolean(opts.skipWorkspaceSelect),
|
|
89
97
|
})));
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type SDKModule = 'product' | 'app' | 'databases' | 'graph' | 'webhooks' | 'notifications' | 'messageBrokers' | 'storage' | 'vector' | 'caches' | 'sessions' | 'quotas' | 'actions' | '
|
|
1
|
+
export type SDKModule = 'product' | 'app' | 'databases' | 'graph' | 'webhooks' | 'notifications' | 'messageBrokers' | 'storage' | 'vector' | 'caches' | 'sessions' | 'quotas' | 'actions' | 'feature' | 'jobs' | 'logs' | 'resilience' | 'health' | 'fallback' | 'secrets' | 'models' | 'cloud';
|
|
2
2
|
export interface SdkProxyContext {
|
|
3
3
|
apiUrl: string;
|
|
4
4
|
workspaceId: string;
|
package/dist/lib/resources.js
CHANGED
|
@@ -11,8 +11,8 @@ export const RESOURCE_MODULES = {
|
|
|
11
11
|
caches: 'caches',
|
|
12
12
|
job: 'jobs',
|
|
13
13
|
jobs: 'jobs',
|
|
14
|
-
feature: '
|
|
15
|
-
features: '
|
|
14
|
+
feature: 'feature',
|
|
15
|
+
features: 'feature',
|
|
16
16
|
action: 'actions',
|
|
17
17
|
actions: 'actions',
|
|
18
18
|
notification: 'notifications',
|
|
@@ -31,7 +31,9 @@ export const RESOURCE_MODULES = {
|
|
|
31
31
|
quotas: 'quotas',
|
|
32
32
|
health: 'health',
|
|
33
33
|
healthcheck: 'health',
|
|
34
|
+
healthchecks: 'health',
|
|
34
35
|
fallback: 'fallback',
|
|
36
|
+
fallbacks: 'fallback',
|
|
35
37
|
model: 'models',
|
|
36
38
|
models: 'models',
|
|
37
39
|
product: 'product',
|
|
@@ -125,6 +127,20 @@ export function buildCrudParams(module, method, productTag, args) {
|
|
|
125
127
|
if (method === 'list')
|
|
126
128
|
return [];
|
|
127
129
|
}
|
|
130
|
+
if (module === 'feature') {
|
|
131
|
+
if (method === 'list' || method === 'fetchAll')
|
|
132
|
+
return [productTag];
|
|
133
|
+
if (method === 'fetch' || method === 'delete') {
|
|
134
|
+
if (!tag)
|
|
135
|
+
throw new Error('tag is required');
|
|
136
|
+
return [tag, productTag];
|
|
137
|
+
}
|
|
138
|
+
if (method === 'update') {
|
|
139
|
+
if (!tag)
|
|
140
|
+
throw new Error('tag is required');
|
|
141
|
+
return [tag, productTag, body ?? {}];
|
|
142
|
+
}
|
|
143
|
+
}
|
|
128
144
|
// Default product-scoped components
|
|
129
145
|
if (method === 'create')
|
|
130
146
|
return [productTag, body ?? {}];
|
package/package.json
CHANGED