@dboio/cli 0.8.0 → 0.9.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.
- package/README.md +157 -57
- package/package.json +1 -1
- package/src/commands/add.js +122 -10
- package/src/commands/clone.js +351 -99
- package/src/commands/deploy.js +1 -1
- package/src/commands/init.js +13 -4
- package/src/commands/input.js +2 -2
- package/src/commands/login.js +69 -0
- package/src/commands/pull.js +1 -0
- package/src/commands/push.js +202 -34
- package/src/commands/rm.js +48 -16
- package/src/lib/delta.js +14 -1
- package/src/lib/diff.js +4 -2
- package/src/lib/filenames.js +151 -0
- package/src/lib/formatter.js +93 -11
- package/src/lib/ignore.js +8 -2
- package/src/lib/input-parser.js +30 -4
- package/src/lib/metadata-templates.js +264 -0
- package/src/lib/structure.js +30 -26
- package/src/lib/ticketing.js +79 -8
package/src/commands/deploy.js
CHANGED
|
@@ -178,7 +178,7 @@ export const deployCommand = new Command('deploy')
|
|
|
178
178
|
log.success(`${entryName} deployed`);
|
|
179
179
|
} else {
|
|
180
180
|
log.error(`${entryName} failed`);
|
|
181
|
-
formatResponse(result, { json: options.json });
|
|
181
|
+
formatResponse(result, { json: options.json, verbose: options.verbose });
|
|
182
182
|
if (!options.all) process.exit(1);
|
|
183
183
|
}
|
|
184
184
|
}
|
package/src/commands/init.js
CHANGED
|
@@ -7,6 +7,7 @@ import { scaffoldProjectDirs, logScaffoldResult } from '../lib/scaffold.js';
|
|
|
7
7
|
import { createDboignore, loadIgnore } from '../lib/ignore.js';
|
|
8
8
|
import { log } from '../lib/logger.js';
|
|
9
9
|
import { checkDomainChange, writeAppJsonDomain } from '../lib/domain-guard.js';
|
|
10
|
+
import { performLogin } from './login.js';
|
|
10
11
|
|
|
11
12
|
export const initCommand = new Command('init')
|
|
12
13
|
.description('Initialize DBO CLI configuration for the current directory')
|
|
@@ -19,8 +20,9 @@ export const initCommand = new Command('init')
|
|
|
19
20
|
.option('--non-interactive', 'Skip all interactive prompts')
|
|
20
21
|
.option('-g, --global', 'Install Claude commands to user home directory (~/.claude/commands/)')
|
|
21
22
|
.option('--local', 'Install Claude commands to project directory (.claude/commands/)')
|
|
22
|
-
.option('--scaffold', 'Create standard project directories (
|
|
23
|
+
.option('--scaffold', 'Create standard project directories (app_version, automation, bins, …)')
|
|
23
24
|
.option('--dboignore', 'Create or reset .dboignore to defaults (use with --force to overwrite)')
|
|
25
|
+
.option('--media-placement <placement>', 'Set media placement when cloning: fullpath or binpath (default: bin)')
|
|
24
26
|
.action(async (options) => {
|
|
25
27
|
// Merge --yes into nonInteractive
|
|
26
28
|
if (options.yes) options.nonInteractive = true;
|
|
@@ -97,7 +99,7 @@ export const initCommand = new Command('init')
|
|
|
97
99
|
}
|
|
98
100
|
|
|
99
101
|
// Ensure sensitive files are gitignored
|
|
100
|
-
await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/config.local.json', '.dbo/ticketing.local.json']);
|
|
102
|
+
await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/config.local.json', '.dbo/ticketing.local.json', 'trash/']);
|
|
101
103
|
|
|
102
104
|
const createdIgnore = await createDboignore();
|
|
103
105
|
if (createdIgnore) log.dim(' Created .dboignore');
|
|
@@ -165,7 +167,7 @@ export const initCommand = new Command('init')
|
|
|
165
167
|
logScaffoldResult(result);
|
|
166
168
|
}
|
|
167
169
|
|
|
168
|
-
// Clone if requested
|
|
170
|
+
// Clone if requested — requires authentication first
|
|
169
171
|
if (options.clone || options.app) {
|
|
170
172
|
let appShortName = options.app;
|
|
171
173
|
if (!appShortName) {
|
|
@@ -177,8 +179,15 @@ export const initCommand = new Command('init')
|
|
|
177
179
|
}]);
|
|
178
180
|
appShortName = appName;
|
|
179
181
|
}
|
|
182
|
+
|
|
183
|
+
// Authenticate before fetching app data from the server
|
|
184
|
+
if (!options.nonInteractive) {
|
|
185
|
+
log.info('Login required to fetch app data from the server.');
|
|
186
|
+
await performLogin(domain, username);
|
|
187
|
+
}
|
|
188
|
+
|
|
180
189
|
const { performClone } = await import('./clone.js');
|
|
181
|
-
await performClone(null, { app: appShortName, domain });
|
|
190
|
+
await performClone(null, { app: appShortName, domain, mediaPlacement: options.mediaPlacement });
|
|
182
191
|
}
|
|
183
192
|
|
|
184
193
|
// Offer Claude Code integration (skip in non-interactive mode)
|
package/src/commands/input.js
CHANGED
|
@@ -124,7 +124,7 @@ export const inputCommand = new Command('input')
|
|
|
124
124
|
result = await client.postMultipart('/api/input/submit', fields, files);
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
formatResponse(result, { json: options.json, jq: options.jq });
|
|
127
|
+
formatResponse(result, { json: options.json, jq: options.jq, verbose: options.verbose });
|
|
128
128
|
if (!result.successful) process.exit(1);
|
|
129
129
|
} else {
|
|
130
130
|
// URL-encoded mode
|
|
@@ -153,7 +153,7 @@ export const inputCommand = new Command('input')
|
|
|
153
153
|
result = await client.postUrlEncoded('/api/input/submit', body);
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
-
formatResponse(result, { json: options.json, jq: options.jq });
|
|
156
|
+
formatResponse(result, { json: options.json, jq: options.jq, verbose: options.verbose });
|
|
157
157
|
if (!result.successful) process.exit(1);
|
|
158
158
|
}
|
|
159
159
|
} catch (err) {
|
package/src/commands/login.js
CHANGED
|
@@ -3,6 +3,75 @@ import { loadConfig, saveUserInfo, saveUserProfile, ensureGitignore } from '../l
|
|
|
3
3
|
import { DboClient } from '../lib/client.js';
|
|
4
4
|
import { log } from '../lib/logger.js';
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Perform authentication against a DBO instance.
|
|
8
|
+
* Prompts for missing credentials interactively.
|
|
9
|
+
* Returns true on success, throws on failure.
|
|
10
|
+
*
|
|
11
|
+
* @param {string|null} domain - Override domain (null = use config)
|
|
12
|
+
* @param {string|null} knownUsername - Pre-filled username (will prompt if null)
|
|
13
|
+
*/
|
|
14
|
+
export async function performLogin(domain, knownUsername) {
|
|
15
|
+
const config = await loadConfig();
|
|
16
|
+
const client = new DboClient({ domain });
|
|
17
|
+
|
|
18
|
+
let username = knownUsername || config.username;
|
|
19
|
+
let password;
|
|
20
|
+
|
|
21
|
+
// Interactive prompt for missing credentials
|
|
22
|
+
const inquirer = (await import('inquirer')).default;
|
|
23
|
+
const answers = await inquirer.prompt([
|
|
24
|
+
{ type: 'input', name: 'username', message: 'Username (email):', default: username || undefined, when: !username },
|
|
25
|
+
{ type: 'password', name: 'password', message: 'Password:', mask: '*' },
|
|
26
|
+
]);
|
|
27
|
+
username = username || answers.username;
|
|
28
|
+
password = answers.password;
|
|
29
|
+
|
|
30
|
+
const params = new URLSearchParams();
|
|
31
|
+
params.append('_username', username);
|
|
32
|
+
params.append('_password', password);
|
|
33
|
+
|
|
34
|
+
const result = await client.postUrlEncoded('/api/authenticate', params.toString());
|
|
35
|
+
|
|
36
|
+
if (!result.successful) {
|
|
37
|
+
throw new Error('Authentication failed');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
log.success(`Authenticated as ${username} on ${await client.getDomain()}`);
|
|
41
|
+
await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/ticketing.local.json']);
|
|
42
|
+
|
|
43
|
+
// Fetch and store user info (non-critical)
|
|
44
|
+
try {
|
|
45
|
+
const userResult = await client.get('/api/output/31799e38f0854956a47f10', { '_format': 'json_raw' });
|
|
46
|
+
const userData = userResult.payload || userResult.data;
|
|
47
|
+
const rows = Array.isArray(userData) ? userData : (userData?.Rows || userData?.rows || []);
|
|
48
|
+
if (rows.length > 0) {
|
|
49
|
+
const row = rows[0];
|
|
50
|
+
const userId = row.ID || row.id || row.UserID || row.userId;
|
|
51
|
+
if (userId) {
|
|
52
|
+
await saveUserInfo({ userId: String(userId) });
|
|
53
|
+
log.dim(` User ID: ${userId}`);
|
|
54
|
+
}
|
|
55
|
+
const firstName = row.FirstName || row.firstname || row.first_name;
|
|
56
|
+
const lastName = row.LastName || row.lastname || row.last_name;
|
|
57
|
+
const email = row.Email || row.email;
|
|
58
|
+
const profile = {};
|
|
59
|
+
if (firstName) profile.FirstName = firstName;
|
|
60
|
+
if (lastName) profile.LastName = lastName;
|
|
61
|
+
if (email) profile.Email = email;
|
|
62
|
+
if (Object.keys(profile).length > 0) {
|
|
63
|
+
await saveUserProfile(profile);
|
|
64
|
+
if (firstName || lastName) log.dim(` Name: ${[firstName, lastName].filter(Boolean).join(' ')}`);
|
|
65
|
+
if (email) log.dim(` Email: ${email}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
log.dim(' Could not retrieve user info (non-critical)');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
6
75
|
export const loginCommand = new Command('login')
|
|
7
76
|
.description('Authenticate with a DBO.io instance')
|
|
8
77
|
.option('-u, --username <value>', 'Username')
|
package/src/commands/pull.js
CHANGED
|
@@ -5,6 +5,7 @@ import { loadConfig } from '../lib/config.js';
|
|
|
5
5
|
import { formatError } from '../lib/formatter.js';
|
|
6
6
|
import { saveToDisk } from '../lib/save-to-disk.js';
|
|
7
7
|
import { log } from '../lib/logger.js';
|
|
8
|
+
import { renameToUidConvention, hasUidInFilename } from '../lib/filenames.js';
|
|
8
9
|
|
|
9
10
|
function collect(value, previous) {
|
|
10
11
|
return previous.concat([value]);
|
package/src/commands/push.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
-
import { readFile, stat, writeFile } from 'fs/promises';
|
|
2
|
+
import { readFile, stat, writeFile, rename as fsRename, mkdir } from 'fs/promises';
|
|
3
3
|
import { join, dirname, basename, extname, relative } from 'path';
|
|
4
4
|
import { DboClient } from '../lib/client.js';
|
|
5
|
-
import { buildInputBody, checkSubmitErrors } from '../lib/input-parser.js';
|
|
5
|
+
import { buildInputBody, checkSubmitErrors, getSessionUserOverride } from '../lib/input-parser.js';
|
|
6
6
|
import { formatResponse, formatError } from '../lib/formatter.js';
|
|
7
7
|
import { log } from '../lib/logger.js';
|
|
8
8
|
import { shouldSkipColumn } from '../lib/columns.js';
|
|
@@ -11,8 +11,11 @@ import { checkStoredTicket, applyStoredTicketToSubmission, clearRecordTicket, cl
|
|
|
11
11
|
import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/modify-key.js';
|
|
12
12
|
import { resolveTransactionKey } from '../lib/transaction-key.js';
|
|
13
13
|
import { setFileTimestamps } from '../lib/timestamps.js';
|
|
14
|
+
import { stripUidFromFilename, renameToUidConvention, hasUidInFilename } from '../lib/filenames.js';
|
|
14
15
|
import { findMetadataFiles } from '../lib/diff.js';
|
|
16
|
+
import { loadIgnore } from '../lib/ignore.js';
|
|
15
17
|
import { detectChangedColumns, findBaselineEntry } from '../lib/delta.js';
|
|
18
|
+
import { BINS_DIR } from '../lib/structure.js';
|
|
16
19
|
|
|
17
20
|
/**
|
|
18
21
|
* Resolve an @reference file path to an absolute filesystem path.
|
|
@@ -90,6 +93,8 @@ async function processPendingDeletes(client, options, modifyKey = null, transact
|
|
|
90
93
|
const extraParams = { '_confirm': options.confirm || 'true' };
|
|
91
94
|
if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
|
|
92
95
|
if (modifyKey) extraParams['_modify_key'] = modifyKey;
|
|
96
|
+
const cachedUser2 = getSessionUserOverride();
|
|
97
|
+
if (cachedUser2) extraParams['_OverrideUserID'] = cachedUser2;
|
|
93
98
|
|
|
94
99
|
const body = await buildInputBody([entry.expression], extraParams);
|
|
95
100
|
|
|
@@ -123,7 +128,7 @@ async function processPendingDeletes(client, options, modifyKey = null, transact
|
|
|
123
128
|
deletedUids.push(entry.UID);
|
|
124
129
|
} else {
|
|
125
130
|
log.error(` Failed to delete "${entry.name}"`);
|
|
126
|
-
formatResponse(retryResponse, { json: options.json, jq: options.jq });
|
|
131
|
+
formatResponse(retryResponse, { json: options.json, jq: options.jq, verbose: options.verbose });
|
|
127
132
|
remaining.push(entry);
|
|
128
133
|
}
|
|
129
134
|
} else if (result.successful) {
|
|
@@ -131,7 +136,7 @@ async function processPendingDeletes(client, options, modifyKey = null, transact
|
|
|
131
136
|
deletedUids.push(entry.UID);
|
|
132
137
|
} else {
|
|
133
138
|
log.error(` Failed to delete "${entry.name}"`);
|
|
134
|
-
formatResponse(result, { json: options.json, jq: options.jq });
|
|
139
|
+
formatResponse(result, { json: options.json, jq: options.jq, verbose: options.verbose });
|
|
135
140
|
remaining.push(entry);
|
|
136
141
|
}
|
|
137
142
|
} catch (err) {
|
|
@@ -140,6 +145,12 @@ async function processPendingDeletes(client, options, modifyKey = null, transact
|
|
|
140
145
|
}
|
|
141
146
|
}
|
|
142
147
|
|
|
148
|
+
// Move __WILL_DELETE__ files to Trash/ for successfully deleted records
|
|
149
|
+
for (const entry of sync.delete) {
|
|
150
|
+
if (!deletedUids.includes(entry.UID)) continue;
|
|
151
|
+
await moveWillDeleteToTrash(entry);
|
|
152
|
+
}
|
|
153
|
+
|
|
143
154
|
// Remove edit entries for successfully deleted records (spec requirement)
|
|
144
155
|
if (deletedUids.length > 0) {
|
|
145
156
|
sync.edit = (sync.edit || []).filter(e => !deletedUids.includes(e.UID));
|
|
@@ -154,6 +165,65 @@ async function processPendingDeletes(client, options, modifyKey = null, transact
|
|
|
154
165
|
}
|
|
155
166
|
}
|
|
156
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Move __WILL_DELETE__-prefixed files associated with a sync entry to Trash/.
|
|
170
|
+
*/
|
|
171
|
+
async function moveWillDeleteToTrash(entry) {
|
|
172
|
+
if (!entry.metaPath) return;
|
|
173
|
+
|
|
174
|
+
const trashDir = join(process.cwd(), 'trash');
|
|
175
|
+
await mkdir(trashDir, { recursive: true });
|
|
176
|
+
|
|
177
|
+
const metaDir = dirname(entry.metaPath);
|
|
178
|
+
const metaBase = basename(entry.metaPath);
|
|
179
|
+
const willDeleteMeta = join(metaDir, `__WILL_DELETE__${metaBase}`);
|
|
180
|
+
|
|
181
|
+
const filesToMove = [];
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
await stat(willDeleteMeta);
|
|
185
|
+
filesToMove.push({ from: willDeleteMeta, to: join(trashDir, metaBase) });
|
|
186
|
+
|
|
187
|
+
// Read the __WILL_DELETE__ metadata to find associated content files
|
|
188
|
+
const rawMeta = await readFile(willDeleteMeta, 'utf8');
|
|
189
|
+
const deletedMeta = JSON.parse(rawMeta);
|
|
190
|
+
for (const col of (deletedMeta._contentColumns || [])) {
|
|
191
|
+
const ref = deletedMeta[col];
|
|
192
|
+
if (ref && String(ref).startsWith('@')) {
|
|
193
|
+
const refFile = String(ref).substring(1);
|
|
194
|
+
const willDeleteContent = join(metaDir, `__WILL_DELETE__${refFile}`);
|
|
195
|
+
try {
|
|
196
|
+
await stat(willDeleteContent);
|
|
197
|
+
filesToMove.push({ from: willDeleteContent, to: join(trashDir, refFile) });
|
|
198
|
+
} catch {}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (deletedMeta._mediaFile && String(deletedMeta._mediaFile).startsWith('@')) {
|
|
202
|
+
const refFile = String(deletedMeta._mediaFile).substring(1);
|
|
203
|
+
const willDeleteMedia = join(metaDir, `__WILL_DELETE__${refFile}`);
|
|
204
|
+
try {
|
|
205
|
+
await stat(willDeleteMedia);
|
|
206
|
+
filesToMove.push({ from: willDeleteMedia, to: join(trashDir, refFile) });
|
|
207
|
+
} catch {}
|
|
208
|
+
}
|
|
209
|
+
} catch {
|
|
210
|
+
// No __WILL_DELETE__ metadata file — nothing to move
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
for (const { from, to } of filesToMove) {
|
|
215
|
+
// Handle Trash collision: append timestamp
|
|
216
|
+
let destPath = to;
|
|
217
|
+
try { await stat(destPath); destPath = `${to}.${Date.now()}`; } catch {}
|
|
218
|
+
try {
|
|
219
|
+
await fsRename(from, destPath);
|
|
220
|
+
log.dim(` Moved to trash: ${basename(destPath)}`);
|
|
221
|
+
} catch (err) {
|
|
222
|
+
log.warn(` Could not move to trash: ${from} — ${err.message}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
157
227
|
/**
|
|
158
228
|
* Push a single file using its companion .metadata.json
|
|
159
229
|
*/
|
|
@@ -178,7 +248,8 @@ async function pushSingleFile(filePath, client, options, modifyKey = null, trans
|
|
|
178
248
|
* Push all records found in a directory (recursive)
|
|
179
249
|
*/
|
|
180
250
|
async function pushDirectory(dirPath, client, options, modifyKey = null, transactionKey = 'RowUID') {
|
|
181
|
-
const
|
|
251
|
+
const ig = await loadIgnore();
|
|
252
|
+
const metaFiles = await findMetadataFiles(dirPath, ig);
|
|
182
253
|
|
|
183
254
|
if (metaFiles.length === 0) {
|
|
184
255
|
log.warn(`No .metadata.json files found in "${dirPath}".`);
|
|
@@ -238,6 +309,26 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
|
|
|
238
309
|
}
|
|
239
310
|
if (missingFiles) { skipped++; continue; }
|
|
240
311
|
|
|
312
|
+
// Check if any companion content file is ignored by .dboignore
|
|
313
|
+
{
|
|
314
|
+
const metaDir = dirname(metaPath);
|
|
315
|
+
let contentIgnored = false;
|
|
316
|
+
for (const col of contentCols) {
|
|
317
|
+
const ref = meta[col];
|
|
318
|
+
if (ref && String(ref).startsWith('@')) {
|
|
319
|
+
const refFile = String(ref).substring(1);
|
|
320
|
+
const contentPath = resolveAtReference(refFile, metaDir);
|
|
321
|
+
const relContent = relative(process.cwd(), contentPath).replace(/\\/g, '/');
|
|
322
|
+
if (ig.ignores(relContent)) {
|
|
323
|
+
log.dim(` Skipped (dboignored): ${basename(metaPath)}`);
|
|
324
|
+
contentIgnored = true;
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (contentIgnored) { skipped++; continue; }
|
|
330
|
+
}
|
|
331
|
+
|
|
241
332
|
// Detect changed columns (delta detection)
|
|
242
333
|
let changedColumns = null;
|
|
243
334
|
if (baseline) {
|
|
@@ -263,7 +354,8 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
|
|
|
263
354
|
|
|
264
355
|
// Pre-flight ticket validation (only if no --ticket flag)
|
|
265
356
|
if (!options.ticket && toPush.length > 0) {
|
|
266
|
-
const
|
|
357
|
+
const recordSummary = toPush.map(r => basename(r.metaPath, '.metadata.json')).join(', ');
|
|
358
|
+
const ticketCheck = await checkStoredTicket(options, `${toPush.length} record(s): ${recordSummary}`);
|
|
267
359
|
if (ticketCheck.cancel) {
|
|
268
360
|
log.info('Submission cancelled');
|
|
269
361
|
return;
|
|
@@ -397,31 +489,66 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
|
|
|
397
489
|
}
|
|
398
490
|
}
|
|
399
491
|
|
|
400
|
-
|
|
492
|
+
// Detect media file upload (binary file changed for media entity)
|
|
493
|
+
const isMediaUpload = entity === 'media' && meta._mediaFile
|
|
494
|
+
&& String(meta._mediaFile).startsWith('@')
|
|
495
|
+
&& changedColumns?.includes('_mediaFile');
|
|
496
|
+
|
|
497
|
+
if (dataExprs.length === 0 && !isMediaUpload) {
|
|
401
498
|
log.warn(`Nothing to push for ${basename(metaPath)}`);
|
|
402
499
|
return false;
|
|
403
500
|
}
|
|
404
501
|
|
|
405
|
-
const fieldLabel = changedColumns ? `${dataExprs.length} changed field(s)` : `${dataExprs.length} field(s)`;
|
|
502
|
+
const fieldLabel = changedColumns ? `${dataExprs.length} changed field(s)${isMediaUpload ? ' + media file' : ''}` : `${dataExprs.length} field(s)`;
|
|
406
503
|
log.info(`Pushing ${basename(metaPath, '.metadata.json')} (${entity}:${rowKeyValue}) — ${fieldLabel}`);
|
|
407
504
|
|
|
408
505
|
// Apply stored ticket if no --ticket flag
|
|
409
|
-
await applyStoredTicketToSubmission(dataExprs, entity, uid || id, uid || id, options);
|
|
506
|
+
const storedTicket = await applyStoredTicketToSubmission(dataExprs, entity, uid || id, uid || id, options);
|
|
410
507
|
|
|
411
508
|
const extraParams = { '_confirm': options.confirm };
|
|
412
509
|
if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
|
|
510
|
+
else if (storedTicket) extraParams['_OverrideTicketID'] = storedTicket;
|
|
413
511
|
if (modifyKey) extraParams['_modify_key'] = modifyKey;
|
|
512
|
+
const cachedUser = getSessionUserOverride();
|
|
513
|
+
if (cachedUser) extraParams['_OverrideUserID'] = cachedUser;
|
|
514
|
+
|
|
515
|
+
let result;
|
|
516
|
+
|
|
517
|
+
if (isMediaUpload) {
|
|
518
|
+
// Media file upload: use multipart/form-data
|
|
519
|
+
const mediaLocalName = String(meta._mediaFile).substring(1);
|
|
520
|
+
const mediaFilePath = resolveAtReference(mediaLocalName, metaDir);
|
|
521
|
+
const mediaUid = meta.UID || meta._id;
|
|
522
|
+
const mediaUploadName = meta.Filename
|
|
523
|
+
|| stripUidFromFilename(mediaLocalName, mediaUid);
|
|
524
|
+
|
|
525
|
+
// Ensure at least one data expression to identify the row for the server
|
|
526
|
+
if (dataExprs.length === 0 && meta.Filename) {
|
|
527
|
+
dataExprs.push(`${rowKeyPrefix}:${rowKeyValue};column:${entity}.Filename=${meta.Filename}`);
|
|
528
|
+
}
|
|
414
529
|
|
|
415
|
-
|
|
416
|
-
|
|
530
|
+
const fields = { ...extraParams };
|
|
531
|
+
for (const expr of dataExprs) {
|
|
532
|
+
const eqIdx = expr.indexOf('=');
|
|
533
|
+
if (eqIdx !== -1) {
|
|
534
|
+
fields[expr.substring(0, eqIdx)] = expr.substring(eqIdx + 1);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
417
537
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
extraParams['_modify_key'] = retryMK.modifyKey;
|
|
423
|
-
body = await buildInputBody(dataExprs, extraParams);
|
|
538
|
+
const files = [{ fieldName: 'file', filePath: mediaFilePath, fileName: mediaUploadName }];
|
|
539
|
+
result = await client.postMultipart('/api/input/submit', fields, files);
|
|
540
|
+
} else {
|
|
541
|
+
let body = await buildInputBody(dataExprs, extraParams);
|
|
424
542
|
result = await client.postUrlEncoded('/api/input/submit', body);
|
|
543
|
+
|
|
544
|
+
// Reactive ModifyKey retry — server rejected because key wasn't set locally
|
|
545
|
+
if (!result.successful && result.messages?.some(m => isModifyKeyError(m))) {
|
|
546
|
+
const retryMK = await handleModifyKeyError();
|
|
547
|
+
if (retryMK.cancel) { log.info('Submission cancelled'); return false; }
|
|
548
|
+
extraParams['_modify_key'] = retryMK.modifyKey;
|
|
549
|
+
body = await buildInputBody(dataExprs, extraParams);
|
|
550
|
+
result = await client.postUrlEncoded('/api/input/submit', body);
|
|
551
|
+
}
|
|
425
552
|
}
|
|
426
553
|
|
|
427
554
|
// Retry with prompted params if needed (ticket, user, repo mismatch)
|
|
@@ -445,11 +572,28 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
|
|
|
445
572
|
const params = retryResult.retryParams || retryResult;
|
|
446
573
|
Object.assign(extraParams, params);
|
|
447
574
|
|
|
448
|
-
|
|
449
|
-
|
|
575
|
+
if (isMediaUpload) {
|
|
576
|
+
const retryLocalName = String(meta._mediaFile).substring(1);
|
|
577
|
+
const retryFilePath = resolveAtReference(retryLocalName, metaDir);
|
|
578
|
+
const retryUid = meta.UID || meta._id;
|
|
579
|
+
const retryUploadName = meta.Filename
|
|
580
|
+
|| stripUidFromFilename(retryLocalName, retryUid);
|
|
581
|
+
const fields = { ...extraParams };
|
|
582
|
+
for (const expr of dataExprs) {
|
|
583
|
+
const eqIdx = expr.indexOf('=');
|
|
584
|
+
if (eqIdx !== -1) {
|
|
585
|
+
fields[expr.substring(0, eqIdx)] = expr.substring(eqIdx + 1);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
const files = [{ fieldName: 'file', filePath: retryFilePath, fileName: retryUploadName }];
|
|
589
|
+
result = await client.postMultipart('/api/input/submit', fields, files);
|
|
590
|
+
} else {
|
|
591
|
+
const body = await buildInputBody(dataExprs, extraParams);
|
|
592
|
+
result = await client.postUrlEncoded('/api/input/submit', body);
|
|
593
|
+
}
|
|
450
594
|
}
|
|
451
595
|
|
|
452
|
-
formatResponse(result, { json: options.json, jq: options.jq });
|
|
596
|
+
formatResponse(result, { json: options.json, jq: options.jq, verbose: options.verbose });
|
|
453
597
|
|
|
454
598
|
// Update metadata on disk if path was changed
|
|
455
599
|
if (metaUpdated) {
|
|
@@ -464,6 +608,25 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
|
|
|
464
608
|
// Clean up per-record ticket on success
|
|
465
609
|
await clearRecordTicket(uid || id);
|
|
466
610
|
|
|
611
|
+
// Post-UID rename: if the record lacked a UID and the server returned one
|
|
612
|
+
try {
|
|
613
|
+
const editResults2 = result.payload?.Results?.Edit || result.data?.Payload?.Results?.Edit || [];
|
|
614
|
+
const addResults2 = result.payload?.Results?.Add || result.data?.Payload?.Results?.Add || [];
|
|
615
|
+
const allResults = [...editResults2, ...addResults2];
|
|
616
|
+
if (allResults.length > 0 && !meta.UID) {
|
|
617
|
+
const serverUID = allResults[0].UID;
|
|
618
|
+
if (serverUID && !hasUidInFilename(basename(metaPath), serverUID)) {
|
|
619
|
+
const config2 = await loadConfig();
|
|
620
|
+
const renameResult = await renameToUidConvention(meta, metaPath, serverUID, allResults[0]._LastUpdated, config2.ServerTimezone);
|
|
621
|
+
if (renameResult.newMetaPath !== metaPath) {
|
|
622
|
+
log.success(` Renamed to ~${serverUID} convention`);
|
|
623
|
+
// Update metaPath reference for subsequent timestamp operations
|
|
624
|
+
// (metaPath is const, but timestamp update below re-reads from meta)
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
} catch { /* non-critical rename */ }
|
|
629
|
+
|
|
467
630
|
// Update file timestamps from server response
|
|
468
631
|
try {
|
|
469
632
|
const editResults = result.payload?.Results?.Edit || result.data?.Payload?.Results?.Edit || [];
|
|
@@ -486,6 +649,11 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
|
|
|
486
649
|
await setFileTimestamps(contentPath, meta._CreatedOn, updated, serverTz);
|
|
487
650
|
}
|
|
488
651
|
}
|
|
652
|
+
// Update media file mtime too
|
|
653
|
+
if (meta._mediaFile && String(meta._mediaFile).startsWith('@')) {
|
|
654
|
+
const mediaPath = join(dirname(metaPath), String(meta._mediaFile).substring(1));
|
|
655
|
+
await setFileTimestamps(mediaPath, meta._CreatedOn, updated, serverTz);
|
|
656
|
+
}
|
|
489
657
|
}
|
|
490
658
|
}
|
|
491
659
|
}
|
|
@@ -497,29 +665,29 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
|
|
|
497
665
|
/**
|
|
498
666
|
* Normalize a local file path for comparison with server-side Path.
|
|
499
667
|
*
|
|
500
|
-
* The
|
|
668
|
+
* The bins/ directory is used for local file organization and does not reflect
|
|
501
669
|
* the actual server-side serving path. This function strips organizational prefixes
|
|
502
670
|
* to enable correct path comparison during push operations.
|
|
503
671
|
*
|
|
504
672
|
* **Directory Structure:**
|
|
505
|
-
* - `
|
|
506
|
-
* - `
|
|
507
|
-
* purely organizational. Files in
|
|
673
|
+
* - `bins/` — Local organizational root (always stripped)
|
|
674
|
+
* - `bins/app/` — Special case: the "app" subdirectory is also stripped because it's
|
|
675
|
+
* purely organizational. Files in bins/app/ are served from the app root without
|
|
508
676
|
* the "app/" prefix on the server.
|
|
509
|
-
* - `
|
|
677
|
+
* - `bins/custom_name/` — Custom bin directories (tpl/, ticket_test/, etc.) are
|
|
510
678
|
* preserved because they represent actual bin hierarchies that serve from their
|
|
511
679
|
* directory name.
|
|
512
680
|
*
|
|
513
681
|
* **Why "app/" is special:**
|
|
514
|
-
* The main app bin is placed in
|
|
682
|
+
* The main app bin is placed in bins/app/ locally for organization, but server-side
|
|
515
683
|
* these files are served from the root path (no "app/" prefix). Other custom bins
|
|
516
684
|
* like "tpl/" maintain their directory name in the serving path.
|
|
517
685
|
*
|
|
518
686
|
* Examples:
|
|
519
|
-
* "
|
|
520
|
-
* "
|
|
521
|
-
* "
|
|
522
|
-
* "
|
|
687
|
+
* "bins/app/assets/css/file.css" → "assets/css/file.css" (strips bins/app/)
|
|
688
|
+
* "bins/tpl/header.html" → "tpl/header.html" (preserves tpl/)
|
|
689
|
+
* "bins/assets/css/file.css" → "assets/css/file.css" (strips bins/ only)
|
|
690
|
+
* "site/MySite/content/page.html" → "site/MySite/content/page.html" (unchanged)
|
|
523
691
|
*
|
|
524
692
|
* @param {string} localPath - Relative path from project root
|
|
525
693
|
* @returns {string} - Normalized path for comparison with metadata Path column
|
|
@@ -531,10 +699,10 @@ function normalizePathForComparison(localPath) {
|
|
|
531
699
|
// Strip leading and trailing slashes
|
|
532
700
|
const cleaned = normalized.replace(/^\/+|\/+$/g, '');
|
|
533
701
|
|
|
534
|
-
// Check if path starts with
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
const withoutBins = cleaned.substring(
|
|
702
|
+
// Check if path starts with bins/ organizational directory
|
|
703
|
+
const binsPrefix = BINS_DIR + '/';
|
|
704
|
+
if (cleaned.startsWith(binsPrefix)) {
|
|
705
|
+
const withoutBins = cleaned.substring(binsPrefix.length);
|
|
538
706
|
|
|
539
707
|
// Special case: strip "app/" organizational subdirectory
|
|
540
708
|
// This is the only special subdirectory - all others (tpl/, assets/, etc.) are preserved
|
package/src/commands/rm.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
-
import { readFile, unlink, stat, rm as fsRm } from 'fs/promises';
|
|
2
|
+
import { readFile, unlink, stat, rm as fsRm, rename } from 'fs/promises';
|
|
3
3
|
import { join, dirname, basename, extname } from 'path';
|
|
4
4
|
import { log } from '../lib/logger.js';
|
|
5
5
|
import { formatError } from '../lib/formatter.js';
|
|
@@ -12,6 +12,7 @@ export const rmCommand = new Command('rm')
|
|
|
12
12
|
.argument('<path>', 'File, metadata.json, or directory to remove')
|
|
13
13
|
.option('-f, --force', 'Skip confirmation prompts')
|
|
14
14
|
.option('--keep-local', 'Only stage server deletion, do not delete local files')
|
|
15
|
+
.option('--hard', 'Immediately delete local files (no Trash; legacy behavior)')
|
|
15
16
|
.action(async (targetPath, options) => {
|
|
16
17
|
try {
|
|
17
18
|
const pathStat = await stat(targetPath).catch(() => null);
|
|
@@ -113,21 +114,35 @@ async function rmFileRecord(metaPath, options, { skipPrompt = false } = {}) {
|
|
|
113
114
|
}
|
|
114
115
|
}
|
|
115
116
|
|
|
116
|
-
// Stage deletion
|
|
117
|
+
// Stage deletion (include metaPath for Trash workflow in push.js)
|
|
117
118
|
const expression = `RowID:del${rowId};entity:${entity}=true`;
|
|
118
|
-
await addDeleteEntry({ UID: uid, RowID: rowId, entity, name: displayName, expression });
|
|
119
|
+
await addDeleteEntry({ UID: uid, RowID: rowId, entity, name: displayName, expression, metaPath });
|
|
119
120
|
log.success(` Staged: ${displayName} → ${expression}`);
|
|
120
121
|
|
|
121
122
|
// Remove from app.json
|
|
122
123
|
await removeAppJsonReference(metaPath);
|
|
123
124
|
|
|
124
|
-
//
|
|
125
|
+
// Handle local files
|
|
125
126
|
if (!options.keepLocal) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
log.dim(` Deleted ${f}`);
|
|
130
|
-
}
|
|
127
|
+
if (options.hard) {
|
|
128
|
+
// --hard: original behavior — immediate delete
|
|
129
|
+
for (const f of localFiles) {
|
|
130
|
+
try { await unlink(f); log.dim(` Deleted ${f}`); } catch {}
|
|
131
|
+
}
|
|
132
|
+
} else {
|
|
133
|
+
// Default: rename to __WILL_DELETE__ prefix
|
|
134
|
+
for (const f of localFiles) {
|
|
135
|
+
const fDir = dirname(f);
|
|
136
|
+
const fName = basename(f);
|
|
137
|
+
if (fName.startsWith('__WILL_DELETE__')) continue; // idempotency guard
|
|
138
|
+
const willDeletePath = join(fDir, `__WILL_DELETE__${fName}`);
|
|
139
|
+
try {
|
|
140
|
+
await rename(f, willDeletePath);
|
|
141
|
+
log.dim(` Staged for delete: ${willDeletePath}`);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
log.warn(` Could not rename ${f}: ${err.message}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
131
146
|
}
|
|
132
147
|
}
|
|
133
148
|
|
|
@@ -196,18 +211,35 @@ async function rmFile(filePath, options) {
|
|
|
196
211
|
}
|
|
197
212
|
|
|
198
213
|
const expression = `RowID:del${rowId};entity:${entity}=true`;
|
|
199
|
-
await addDeleteEntry({ UID: uid, RowID: rowId, entity, name: displayName, expression });
|
|
214
|
+
await addDeleteEntry({ UID: uid, RowID: rowId, entity, name: displayName, expression, metaPath });
|
|
200
215
|
log.success(`Staged deletion: ${expression}`);
|
|
201
216
|
|
|
202
217
|
await removeAppJsonReference(metaPath);
|
|
203
218
|
|
|
204
219
|
if (!options.keepLocal) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
220
|
+
if (options.hard) {
|
|
221
|
+
// --hard: original behavior — immediate delete
|
|
222
|
+
for (const f of localFiles) {
|
|
223
|
+
try {
|
|
224
|
+
await unlink(f);
|
|
225
|
+
log.dim(` Deleted ${f}`);
|
|
226
|
+
} catch {
|
|
227
|
+
log.warn(` Could not delete ${f} (may not exist)`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
} else {
|
|
231
|
+
// Default: rename to __WILL_DELETE__ prefix
|
|
232
|
+
for (const f of localFiles) {
|
|
233
|
+
const fDir = dirname(f);
|
|
234
|
+
const fName = basename(f);
|
|
235
|
+
if (fName.startsWith('__WILL_DELETE__')) continue; // idempotency guard
|
|
236
|
+
const willDeletePath = join(fDir, `__WILL_DELETE__${fName}`);
|
|
237
|
+
try {
|
|
238
|
+
await rename(f, willDeletePath);
|
|
239
|
+
log.dim(` Staged for delete: ${willDeletePath}`);
|
|
240
|
+
} catch (err) {
|
|
241
|
+
log.warn(` Could not rename ${f}: ${err.message}`);
|
|
242
|
+
}
|
|
211
243
|
}
|
|
212
244
|
}
|
|
213
245
|
}
|