@ductape/cli 0.3.21 → 0.3.23
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/db.js +17 -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/index.js +12 -4
- 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
|
package/dist/commands/db.js
CHANGED
|
@@ -48,6 +48,22 @@ export async function runDb(method, opts) {
|
|
|
48
48
|
printJson(result, Boolean(opts.json));
|
|
49
49
|
}
|
|
50
50
|
const DB_ACTION_VERBS = ['create', 'update', 'get', 'list', 'delete', 'execute', 'dispatch'];
|
|
51
|
+
const DB_ACTION_OPERATIONS = ['query', 'insert', 'update', 'delete', 'aggregate', 'count'];
|
|
52
|
+
function validateDatabaseActionCreateBody(body) {
|
|
53
|
+
const operation = String(body.operation ?? '');
|
|
54
|
+
if (!DB_ACTION_OPERATIONS.includes(operation)) {
|
|
55
|
+
throw new Error(`Invalid database action operation "${operation}". Use one of: ${DB_ACTION_OPERATIONS.join(', ')}. ` +
|
|
56
|
+
'For MongoDB find/findOne actions use operation "query".');
|
|
57
|
+
}
|
|
58
|
+
const template = body.template;
|
|
59
|
+
if (!template || (typeof template !== 'object' && typeof template !== 'string')) {
|
|
60
|
+
throw new Error('template is required and must be an object for MongoDB or a SQL string.');
|
|
61
|
+
}
|
|
62
|
+
if (JSON.stringify(template).includes('$Input{')) {
|
|
63
|
+
throw new Error('Database action placeholders use {{name}}, not $Input{name}. ' +
|
|
64
|
+
'Example MongoDB query template: { "where": { "email": "{{email}}" } }.');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
51
67
|
/**
|
|
52
68
|
* Database Actions — reusable, parameterized query/mutation definitions saved against a
|
|
53
69
|
* database component (databases.action.* in the SDK). Definition management is
|
|
@@ -75,6 +91,7 @@ export async function runDbActions(verb, opts, extraArgs) {
|
|
|
75
91
|
throw new Error('create requires a body: { tag: "product:database:action", name, tableName, ' +
|
|
76
92
|
'operation, template, description?, filterTemplate? } (pass -f <file.json>).');
|
|
77
93
|
}
|
|
94
|
+
validateDatabaseActionCreateBody(body);
|
|
78
95
|
method = 'action.create';
|
|
79
96
|
params = [body];
|
|
80
97
|
break;
|
|
@@ -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;
|
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
|
})));
|
|
@@ -814,8 +822,8 @@ const dbActions = db
|
|
|
814
822
|
'query/insert/update/delete calls keep their raw table/where/data shape.');
|
|
815
823
|
dbActions
|
|
816
824
|
.command('create')
|
|
817
|
-
.description('Create a database action
|
|
818
|
-
.requiredOption('--action-file <path>', 'JSON
|
|
825
|
+
.description('Create a saved database action; MongoDB query example uses operation "query" and template { "where": { "email": "{{email}}" } }')
|
|
826
|
+
.requiredOption('--action-file <path>', 'JSON definition with tag, name, description, tableName, operation, and template')
|
|
819
827
|
.option('--json', 'JSON output')
|
|
820
828
|
.action(wrap((opts) => runDbActions('create', { file: opts.actionFile, json: opts.json }, [])));
|
|
821
829
|
dbActions
|
package/package.json
CHANGED